Define Primitive Types for the SimpliLang Compiler

Mục tiêu: Define the fundamental primitive data types (int, float, bool) for the statically-typed language SimpliLang. This task involves creating a Rust enum to represent these types within the compiler’s Abstract Syntax Tree (AST).


With our project now neatly organized into modules, we’ve transitioned from setting up the infrastructure to the creative and foundational process of designing SimpliLang itself. The very first decision we must make, the one upon which all other features will be built, is to define the fundamental building blocks of data in our language: its primitive types.

Why Start with Types? The Role of a Type System

Every programming language needs a way to classify the data it operates on. Is this value a whole number? A number with a decimal point? A true/false value? This system of classification is called a type system.

Our language, SimpliLang, will be statically typed. This is a crucial design choice. It means that the type of every variable and expression must be known at compile time—that is, before the program is ever run. Our compiler will be responsible for enforcing these rules. This approach has significant advantages:

  • Error Prevention: It catches a huge class of bugs early. An operation like "hello" * 5 would be a type error that our compiler can detect, preventing a runtime crash.
  • Performance: When the compiler knows the exact type (and thus, the size and layout in memory) of every piece of data, it can generate highly optimized machine code.
  • Readability and Maintainability: Explicit types act as documentation, making it easier for developers to understand what a piece of code is supposed to do.

Our first task is to decide on the simplest, most essential set of types that will form the core of SimpliLang.

Defining SimpliLang’s Primitives

For SimpliLang, we will start with three of the most universally recognized primitive types.

  1. int: This type will represent whole numbers (integers). For our language, we’ll make a concrete decision: int will correspond to a 64-bit signed integer (equivalent to Rust’s i64). This is a modern, practical choice that provides a large range of values and aligns well with 64-bit computer architectures.
  2. float: This type will represent floating-point numbers (numbers with a fractional component). We will define float to be a 64-bit double-precision floating-point number (equivalent to Rust’s f64). This is the standard for floating-point math in most modern languages, offering a good balance of precision and range.
  3. bool: This type represents a boolean or logical value. It can only hold one of two possible values: true or false. This is essential for control flow, such as in if statements.

Representing Types in Our Compiler’s AST

Now that we’ve made these design decisions, we need a way to represent them inside our compiler. When the parser analyzes SimpliLang code, it will build an Abstract Syntax Tree (AST). This tree needs to store the type information for variables, function parameters, and return values.

This is a perfect use case for a Rust enum. An enum (or enumeration) is a type that can be one of several possible variants. We can define an enum where each variant corresponds to one of our primitive types.

Let’s put this into practice by adding our first piece of real compiler logic to the ast module you created in the previous step.

First, create a new file inside the src/ast/ directory named types.rs.

touch src/ast/types.rs

Now, add the following code to this new src/ast/types.rs file.

// src/ast/types.rs

/// Represents the primitive data types available in SimpliLang.
///
/// We use a Rust enum to define a new type that can only be one of the specified
/// variants. This ensures type safety within our compiler itself. We can't accidentally
/// represent a type that doesn't exist in our language.
///
/// #[derive(...)] is an attribute that asks the Rust compiler to automatically
/// generate implementations for certain traits (interfaces).
/// - `Debug`: Allows the type to be printed for debugging purposes (e.g., using `println!("{:?}", my_type);`).
/// - `Clone`: Allows creating a copy of a value of this type.
/// - `Copy`: A special kind of `Clone` for simple types that can be copied by just copying their bits.
///           This makes them very cheap to pass around.
/// - `PartialEq`, `Eq`: Allow values of this type to be compared for equality (e.g., `type1 == type2`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PrimitiveType {
    /// Represents a 64-bit signed integer (`i64`).
    Int,
    /// Represents a 64-bit floating-point number (`f64`).
    Float,
    /// Represents a boolean value (`true` or `false`).
    Bool,
}

Next, we need to tell the ast module that this new file exists and that we want to make its contents available. Open src/ast/mod.rs and add the following lines.

// src/ast/mod.rs

// This line declares a new public module named `types` based on the file `ast/types.rs`.
pub mod types;

// This is a common Rust pattern called "re-exporting". It makes the types
// defined inside the `types` module (like `PrimitiveType`) directly accessible
// from the `ast` module.
// So, instead of writing `ast::types::PrimitiveType`, other parts of our compiler
// can simply write `ast::PrimitiveType`. This is a nice convenience.
pub use types::*;

By completing these steps, you have not only defined the types for SimpliLang but also implemented the internal data structure that your compiler will use to represent them. This PrimitiveType enum is the very first node in what will become our language’s Abstract Syntax Tree.

Next Steps

With our fundamental data types defined, the next logical step is to define the language’s reserved words or keywords. These are words like let, if, and fn that have special meaning to the compiler and cannot be used as variable names.

Further Reading

Define the Keywords for SimpliLang

Mục tiêu: Establish the set of reserved keywords and boolean literals that form the core grammar of the SimpliLang programming language, including function definitions, variable declarations, and conditional logic.


Excellent work defining the primitive types for SimpliLang! You’ve established the fundamental categories of data our language will work with. Now, just as we have special types, we need special words that form the very grammar of our language. These are the keywords, and defining them is our next crucial design step.

From Data Types to Language Grammar: Introducing Keywords

In the previous task, you defined int, float, and bool. These names have a special meaning to our compiler when used in type annotations. Similarly, we need to reserve a set of words that will signal specific actions or structures to the compiler. These reserved words, or keywords, are the structural verbs and conjunctions of our programming language. They cannot be used as names for variables or functions because they are dedicated to forming the language’s syntax.

By defining a fixed set of keywords, we create a predictable and unambiguous grammar that our parser can understand. When the parser encounters the word if, it knows a conditional expression is about to begin. If it sees fn, it prepares to parse a function definition.

For SimpliLang, we will start with a small but powerful set of keywords that enable modern procedural programming.

The Core Keywords of SimpliLang

Let’s define the role of each keyword in our language’s syntax. This is a design specification; we are not writing any code to implement them just yet. This design will guide us when we build our lexer and parser in the upcoming steps.

  • fn Purpose: Function Definition. The fn keyword is the entry point for defining a new function. It signals to the compiler that an identifier (the function’s name), a parameter list, a return type, and a function body will follow. Syntax Example: fn add(a: int, b: int) -> int { ... }
  • let Purpose: Variable Declaration. The let keyword is used to declare a new local variable. It binds a value to a name within the current scope. Following let will be the variable’s name, its type, and an initial value. Syntax Example: let x: int = 10;
  • if and else Purpose: Conditional Execution. These keywords are used to create branches in our code. The if keyword is followed by a condition (which must be of type bool). If the condition is true, the block of code immediately following it is executed. The optional else keyword provides an alternative block of code to execute if the condition is false. Syntax Example: if is_ready { do_something() } else { wait() }
  • return Purpose: Return from a Function. The return keyword is used to exit the current function and optionally pass a value back to the caller. The type of the value being returned must match the function’s declared return type. Syntax Example: return x + 1;

Special Literals: true and false

While they often feel like keywords, true and false hold a special place. They are not instructions or structural markers like if or let; instead, they are the literal values for the bool type you defined in the last step.

  • true and false Purpose: Boolean Literals. These represent the two possible states of the bool primitive type. They are used in conditional logic and as the result of comparison operations. Because they have this built-in meaning, they are also reserved words and cannot be used as variable names.

By defining these keywords, you have laid down the grammatical foundation of SimpliLang. Our lexer will be taught to recognize these words as special Tokens, distinct from regular identifiers, and our parser will rely on their presence to understand the structure of the code it is analyzing.

Next Steps

We’ve now defined our primitive types and our keywords. The next logical step is to combine them to design the concrete syntax for one of our core language features: how a user will declare and assign values to variables.

Further Reading

Designing the Syntax for Variable Declaration

Mục tiêu: Define the precise syntax for declaring variables in a new programming language, including the keyword, identifier rules, type annotation, initial value assignment, and statement termination. This task also covers design decisions like immutability, explicit typing, and required initialization.


Having defined the “what” (our primitive types) and the “who” (our keywords), it’s time to combine them into our first complete grammatical sentence. In this task, we will design the precise syntax for one of the most fundamental operations in any programming language: declaring a variable. This is where the abstract concepts we’ve discussed so far become concrete rules that our compiler will eventually enforce.

From Concepts to Concrete Syntax

In the last task, we identified let as the keyword for variable declarations. We also have our types: int, float, and bool. How do we combine these into an unambiguous, easy-to-parse, and easy-to-read statement?

A well-designed syntax for a statically-typed language should clearly communicate three essential pieces of information to the compiler about a new variable:

  1. Its Name (Identifier): How will we refer to this variable later?
  2. Its Type: What kind of data can this variable hold? This is crucial for static analysis.
  3. Its Initial Value: What is the variable’s value at the moment of its creation?

With these requirements in mind, we will establish the following syntax for variable declarations in SimpliLang:

let <identifier>: <type> = <expression>;

This structure is heavily inspired by modern statically-typed languages like Rust and Swift. It is explicit, readable, and leaves no room for ambiguity. Let’s break down each component of this rule.


Anatomy of a let Statement

1. The let Keyword

This is the starting point. When our parser encounters the let token, it will know that a variable declaration statement is beginning.

2. The <identifier>

An identifier is the name given to a variable, function, or other entity. For SimpliLang, we will define the following rules for a valid identifier: * It must start with an alphabetic character (a-z, A-Z) or an underscore (_). * After the first character, it can contain alphanumeric characters (a-z, A-Z, 0-9) or underscores. * It cannot be a reserved keyword (e.g., you can’t name a variable let or if).

Examples of valid identifiers: my_variable, score, Player1, _temp. Examples of invalid identifiers: 1player (starts with a number), my-variable (contains a hyphen).

3. The Colon Separator (:)

A colon : will be used to separate the variable’s name from its type annotation. This is a common and clear syntactic marker.

4. The <type>

This part specifies the data type of the variable. It must be one of the primitive types we defined earlier: int, float, or bool. By requiring an explicit type annotation, we make our compiler’s job much simpler during the semantic analysis phase. This design choice is called explicit typing.

5. The Assignment Operator (=)

The single equals sign = is the assignment operator. It indicates that the value on its right-hand side will be assigned to the variable on its left-hand side.

6. The <expression>

An expression is any piece of code that evaluates to a value. For now, the simplest expressions are literals—the direct representation of a value in code. * An int literal: 10, 0, -42 * A float literal: 3.14, 9.81 * A bool literal: true, false

Later, this <expression> part will grow to include more complex things like arithmetic (x + 5), variable lookups (my_other_variable), and function calls (calculate_score()). The type of the expression on the right must match the declared <type> on the left.

7. The Statement Terminator (;)

The semicolon ; marks the end of the statement. This is a crucial element for the parser, as it clearly signals where one statement ends and the next one might begin, allowing for statements to be written on the same line or across multiple lines.


Design Decisions and Rationale

When designing a language, every choice has implications. Let’s discuss why we’ve made these specific choices for SimpliLang.

  • Immutability by Default: In SimpliLang, variables declared with let will be immutable. This means that once a variable is assigned a value, that value can never be changed. This is a modern language feature, popularized by languages like Rust, that helps prevent a wide range of bugs by making code easier to reason about. We are intentionally keeping the language simple by not adding a way to declare mutable variables (like a mut keyword) for now.
  • Explicit Typing vs. Type Inference: We are requiring the programmer to write out the type (let x: int = 10;). We are not allowing type inference (let x = 10;), where the compiler would have to deduce that 10 is an int and make x an int automatically. While inference is a powerful feature, it adds significant complexity to the semantic analysis phase of the compiler. By requiring explicit types, we make our first version of the compiler much easier to build correctly.
  • Required Initialization: SimpliLang will not allow uninitialized variables. A statement like let x: int; will be a syntax error. Every let binding must provide an initial value. This is another choice that simplifies the compiler and prevents a common source of runtime errors: using a variable before it has been given a meaningful value.

Putting It All Together: Examples

Here are some examples of valid variable declarations using the syntax we’ve just designed:

// Declaring an integer to store a player's score.
let score: int = 100;

// Declaring a float for a physical constant.
let gravity: float = 9.81;

// Declaring a boolean to track a game state.
let is_game_over: bool = false;

These three lines perfectly illustrate our design: they use the let keyword, provide a valid identifier, an explicit type annotation, and an initial value of a matching type, all terminated by a semicolon.

Next Steps

We have successfully designed a robust syntax for variable declarations. You may have noticed the <expression> part is currently limited to simple literal values. To make our language truly useful, we need to be able to perform calculations. This leads directly to our next task: defining the set of arithmetic and comparison operators that SimpliLang will support.

Further Reading

SimpliLang: Defining Arithmetic and Comparison Operators

Mục tiêu: Define the semantics and strict type rules for arithmetic (+, -, *, /) and comparison (==, !=, <, >) operators in the SimpliLang programming language, specifying how they interact with int, float, and bool types.


You’ve brilliantly designed the syntax for declaring variables, giving SimpliLang a way to store data. However, a language isn’t just about storing data; it’s about transforming it. The <expression> part of your let statement, let score: int = <expression>;, is currently limited to simple literals like 100. To unlock the real power of computing, we must introduce operators—the symbols that perform the actual work.

From Literals to Computations: Defining Operators

Operators are the verbs of expressions. They are special symbols that represent computations like addition, multiplication, or comparison. When our parser sees an expression like 5 + 5, it will recognize + as an operator that takes two operands (the 5s) and produces a new value.

For SimpliLang, we will design two fundamental categories of operators: those for mathematical calculations (arithmetic operators) and those for logical comparisons (comparison operators). Defining these involves more than just picking symbols; we must also define the strict rules—the semantics—governing their use, especially concerning data types.


Arithmetic Operators

These operators perform standard mathematical calculations. For SimpliLang, we will support the four basic arithmetic functions.

  • + (Addition)

    • Semantics: Computes the sum of two numbers.
    • Type Rules: This operator is defined for int and float types, but they cannot be mixed.
      • int + int results in an int.
      • float + float results in a float.
      • An operation like int + float will be considered a type error and rejected by the compiler. This design choice simplifies our type-checking system significantly.
  • - (Subtraction)

    • Semantics: Computes the difference between two numbers.
    • Type Rules: Same as addition.
      • int - int results in an int.
      • float - float results in a float.
      • Mixed types are a compile-time error.
  • * (Multiplication)

    • Semantics: Computes the product of two numbers.
    • Type Rules: Same as addition.
      • int * int results in an int.
      • float * float results in a float.
      • Mixed types are a compile-time error.
  • / (Division)

    • Semantics: Computes the quotient of two numbers.
    • Type Rules: Same as addition.
      • int / int results in an int (integer division, truncating any remainder).
      • float / float results in a float.
      • Mixed types are a compile-time error.
    • Runtime Consideration: Division by zero is an operation that cannot be checked at compile time (e.g., x / y where y’s value is unknown). This will result in a runtime error (a panic or crash), not a compile-time error.

Comparison Operators

These operators are used to compare two values, and they form the foundation of all decision-making and control flow in a program. The most crucial rule for this category is that the result of any comparison is always a bool (true or false).

  • == (Equal)

    • Semantics: Tests if two values are equal.
    • Type Rules: The operands must be of the same type.
      • int == int results in a bool.
      • float == float results in a bool.
      • bool == bool results in a bool.
      • Comparing different types (e.g., int == float) is a type error.
  • != (Not Equal)

    • Semantics: Tests if two values are not equal.
    • Type Rules: Same as the equality operator. The result is always a bool.
  • < (Less Than)

    • Semantics: Tests if the left operand is numerically less than the right operand.
    • Type Rules: This operator is only meaningful for types that have an ordering (i.e., numbers).
      • int < int results in a bool.
      • float < float results in a bool.
      • This operator is not defined for bool types. true < false would be a type error.
  • > (Greater Than)

    • Semantics: Tests if the left operand is numerically greater than the right operand.
    • Type Rules: Same as the less-than operator. The result is always a bool.

Summary of Operator Rules

Here is a quick reference table for the design decisions we’ve just made:

Operator Name Example Valid Operand Types Result Type
+ Addition 5 + 2 (int, int) (float, float) int or float
- Subtraction 5 - 2 (int, int) (float, float) int or float
* Multiplication 5 * 2 (int, int) (float, float) int or float
/ Division 5 / 2 (int, int) (float, float) int or float
== Equal x == 10 (int, int) (float, float) (bool, bool) bool
!= Not Equal x != 10 (int, int) (float, float) (bool, bool) bool
< Less Than y < 3.14 (int, int) (float, float) bool
> Greater Than y > 3.14 (int, int) (float, float) bool

By defining these operators and their associated type rules, we have added a rich layer of computational ability to SimpliLang. A declaration like let is_adult: bool = age > 17; is now a valid and meaningful statement according to our language design.

Next Steps

We can now form expressions like 2 + 3 and x > 5. But what happens in a more complex expression like 2 + 3 * 4? Should this be evaluated as (2 + 3) * 4 (which is 20) or 2 + (3 * 4) (which is 14)? To resolve this ambiguity, our next task is to define the rules of operator precedence and associativity.

Further Reading

SimpliLang: Defining Operator Precedence and Associativity

Mục tiêu: Establish formal rules for operator precedence and associativity in the SimpliLang compiler to resolve expression ambiguity, ensuring that mathematical and comparison operations are parsed in a predictable and unambiguous way.


In the last task, we successfully defined the set of arithmetic and comparison operators for SimpliLang, giving our language the ability to perform computations. Now, we face a critical question of ambiguity. When a programmer writes let result: int = 5 + 2 * 10;, how should the compiler interpret it? Is it (5 + 2) * 10 (evaluating to 70), or is it 5 + (2 * 10) (evaluating to 25)?

Without clear rules, the same line of code could produce different results, making the language unpredictable and useless. To solve this, we must formally establish the operator precedence and associativity rules. This design step creates a predictable order of operations that will be hard-coded into our parser’s logic later on.

The Problem of Ambiguity

Consider the expression 10 > 5 + 2. A human trained in mathematics instinctively knows this means “is 10 greater than the result of 5 + 2?”. The compiler, however, knows nothing by default. It could just as easily interpret this as “(is 10 greater than 5?) plus 2”, which would be a nonsensical type error (bool + int).

To ensure that expressions are parsed in a single, unambiguous way, we introduce two key concepts from parsing theory.

Concept 1: Operator Precedence (or “Binding Power”)

Operator precedence defines which operators are evaluated first in an expression containing a mix of different operators. An operator with higher precedence is said to “bind more tightly” to its operands.

This concept is familiar from mathematics, often remembered by acronyms like PEMDAS or BODMAS (Parentheses/Brackets, Exponents/Orders, Multiplication/Division, Addition/Subtraction). The rule is simple: multiplication and division are performed before addition and subtraction. We will adopt a similar, standard hierarchy for SimpliLang.

For the operators we have defined so far, we will establish the following precedence levels, from highest to lowest:

  1. Level 2 (Highest Precedence): Multiplicative Operators

    • Includes: *, /
    • These operators will be evaluated first. In 5 + 2 * 10, the * has higher precedence, so 2 * 10 is treated as a single unit.
  2. Level 1: Additive Operators

    • Includes: +, -
    • These are evaluated after all multiplicative operations are complete.
  3. Level 0 (Lowest Precedence): Comparison Operators

    • Includes: ==, !=, <, >
    • These are evaluated last. This is a crucial rule that allows expressions like let is_larger: bool = var_a + 5 > var_b * 2; to be parsed correctly as (var_a + 5) > (var_b * 2). The arithmetic is performed on both sides first, and only then are the final results compared.

Concept 2: Operator Associativity

Precedence tells us how to handle different operators, but what about operators that have the same precedence? Consider 10 - 4 - 2. Precedence alone doesn’t help us here. Associativity defines the evaluation order for a sequence of operators at the same precedence level.

There are three kinds of associativity:

  • Left-associative: The expression is evaluated from left to right. This is the most common for arithmetic.
    • 10 - 4 - 2 is interpreted as (10 - 4) - 2, which evaluates to 4.
  • Right-associative: The expression is evaluated from right to left. This is less common but is used for things like the assignment operator in C-family languages (e.g., a = b = 5 is a = (b = 5)).
  • Non-associative: A sequence of these operators is simply disallowed. a < b < c would be a syntax error. The programmer must use parentheses to be explicit.

For SimpliLang, we will make the following design choices:

  • The additive (+, -) and multiplicative (*, /) operators will be left-associative. This aligns with natural mathematical convention.
  • The comparison (==, !=, <, >) operators will be non-associative. This is a safety feature that prevents confusing constructs like a == b == c. In most languages, this doesn’t check if all three are equal but instead evaluates to (a == b) == c, which is a type error (bool == int). By making it a syntax error, we force the programmer to write clearer code, such as a == b && b == c (once we introduce logical operators).

The Official Rules for SimpliLang

Let’s formalize our design in a definitive table. This table will be the blueprint for the expression-parsing logic we’ll write in a future step.

Precedence Level Operator(s) Description Associativity
2 (Highest) *, / Multiplicative Left-associative
1 +, - Additive Left-associative
0 (Lowest) ==, !=, <, > Comparison Non-associative

With these rules, the expression 5 + 2 * 10 > 15 - 4 is now unambiguously parsed by our compiler as: ((5 + (2 * 10)) > (15 - 4))

A Note on Overriding: Parentheses

Of course, there will be times when a programmer needs to override these default rules. The standard way to do this is with parentheses (). Any expression inside parentheses is evaluated as a single unit first, regardless of the operators outside it. For example, (5 + 2) * 10 would force the addition to happen before the multiplication. We will incorporate parentheses as the highest level of precedence when we build our parser.

Next Steps

We have now established a clear and unambiguous set of rules for evaluating mathematical and comparison expressions. The boolean true or false values produced by our comparison operators are the key to decision-making in programming. This leads us directly to the next task: designing the syntax for if/else conditional expressions, which will be the first control-flow construct in SimpliLang.

Further Reading

Design if/else Control Flow for SimpliLang

Mục tiêu: Design the syntax and semantics for the if/else construct in SimpliLang, establishing it as a type-safe expression for conditional logic and control flow.


Excellent! By establishing clear rules for operator precedence and associativity, you’ve ensured that expressions in SimpliLang are unambiguous and predictable. The comparison operators now reliably produce boolean (true or false) values. The natural next question is: how can our language act on these boolean values? How can it make decisions?

This brings us to one of the most fundamental concepts in programming: control flow. We need a way to direct the path of execution, running certain pieces of code only when specific conditions are met. The most essential tool for this is the if/else construct, and in this task, we will design its syntax and semantics for SimpliLang.

From Boolean Values to Code Branches

In the last few tasks, we’ve designed expressions like age > 17 or score == 100. These are powerful because they distill a question down to a simple true or false. Now, we will design the grammatical structure that uses this answer to create a fork in the road for our program.

The syntax for if/else in SimpliLang will be familiar, readable, and powerful. It will be built upon the keywords if and else that we’ve already reserved.

The Core Syntax of if/else

The basic structure will look like this:

if <condition> {
    // "then" block: Code to execute if the condition is true.
} else {
    // "else" block: Code to execute if the condition is false.
}

Let’s break down the components and define their rules:

  1. The if Keyword: This signals the beginning of a conditional block.
  2. The <condition>:

    • This is an expression that is evaluated at runtime.
    • Semantic Rule: The crucial rule for our statically-typed language is that the <condition> expression must evaluate to a bool type. An expression like if x + 5 { ... } where x is an integer will be a compile-time type error. This strictness prevents a common class of bugs found in more loosely-typed languages.
  3. The Blocks ({...}):

    • The bodies of both the “then” branch (after if) and the optional “else” branch are enclosed in curly braces {}.
    • These braces define a new scope. Variables declared with let inside a block only exist within that block.
    • The code inside a block is a sequence of one or more statements.

A Powerful Design Choice: if is an Expression

In many older languages (like C or Java), if is a statement. A statement performs an action but does not produce a value. In modern languages like Rust, if is an expression. An expression evaluates to a value. This is a subtle but incredibly powerful distinction.

For SimpliLang, we will adopt the modern approach: if is an expression.

This means you can use an if/else construct on the right-hand side of a let binding. This allows for incredibly concise and declarative code.

Consider this example:

// Use an if/else expression to determine the initial value of a variable.
let message: string = if score > 100 {
    "High score!"
} else {
    "Keep trying!"
};

(Note: We haven’t defined a string type yet, but this illustrates the concept.)

To make this work, we must define a clear semantic rule:

  • Type Consistency Rule: For an if/else to be used as an expression, the value produced by the then block must have the exact same type as the value produced by the else block. The type of the entire if/else expression is this common type.
  • Block Value: The value of a block is the value of the final expression within it. To make this clear, we will adopt a rule from Rust: if the last statement in a block is an expression without a trailing semicolon, that is the value the block produces.

For example, to assign the greater of two numbers:

let a: int = 10;
let b: int = 20;

let max: int = if a > b {
    a // No semicolon, so 'a' is the value of this block.
} else {
    b // No semicolon, so 'b' is the value of this block.
};
// 'max' is now 20.

An attempt to mix types would be a compile-time error:

// TYPE ERROR!
let result: int = if condition {
    100      // This block produces an 'int'.
} else {
    false    // This block produces a 'bool'.
};
// The compiler will reject this because 'int' and 'bool' are not the same type.

Handling if without else

What if a programmer only wants to do something if a condition is true, and do nothing otherwise?

if is_logging_enabled {
    print_debug_info(); // A function call (we will design these soon).
}

This is a perfectly valid use case. However, if if is an expression, what value does it produce if the condition is false and there is no else block?

To resolve this, we will set a simple rule:

  • An if with an else is an expression that can produce a value.
  • An if without an else is a statement. It cannot produce a value and therefore cannot be used on the right side of a let assignment. Its purpose is only to execute code for its side effects (like calling a function).

This design choice keeps the language simple and avoids the need for a special “unit” or “void” type at this stage, while still providing the full power of if expressions where they are needed.

Next Steps

We’ve now designed a way to declare variables, perform computations, and control the flow of execution based on the results. The next logical step in building a structured language is to encapsulate and reuse logic. This leads us directly to our next task: designing the syntax for function definitions, using the fn keyword we reserved earlier.

Further Reading

Design Function Definition Syntax for SimpliLang

Mục tiêu: Define the syntax and semantics for creating functions in the SimpliLang programming language. This includes specifying the fn keyword, function name, parameter lists, return types, function body, and the return statement, following a Rust-inspired design.


You’ve done a fantastic job designing the control flow for SimpliLang with the if/else expression. This gives your language the ability to make decisions and execute different code paths. Now, we need to introduce a way to organize and reuse these blocks of logic. While you could write a very long program in a single sequence of let bindings and if blocks, this quickly becomes unmanageable. The primary tool for creating clean, modular, and reusable code is the function.

In this task, we will design the syntax and semantics for defining functions in SimpliLang, using the fn keyword you’ve already reserved.

From Logic Blocks to Reusable Abstractions

A function is a named block of code that performs a specific task. It can take inputs (called parameters) and produce an output (called a return value). By defining a function, we are creating a reusable abstraction. We can write the logic for a complex calculation once and then call that function by name from many different places in our code.

The syntax for defining a function needs to be clear and unambiguous, providing the compiler with all the information it needs to perform type checking and generate correct code. For SimpliLang, we will adopt a modern, explicit syntax inspired by languages like Rust.

The Anatomy of a SimpliLang Function

The complete syntax for a function definition in SimpliLang will be:

fn <function_name>(<parameter_list>) -> <return_type> {
    // Function body: a sequence of statements
    return <expression>;
}

This structure is designed to be easily readable and parsable. Let’s dissect each component.

1. The fn Keyword

As we’ve decided, the fn keyword marks the beginning of a function definition. When the parser sees this token, it knows to expect the rest of the function’s structure.

2. The <function_name>

This is the identifier that will be used to call the function. It follows the exact same rules as variable identifiers: * Must start with a letter (a-z, A-Z) or an underscore _. * Can contain letters, numbers, and underscores after the first character. * Cannot be a keyword. * Examples: add, calculate_gravity, is_valid.

3. The Parameter List (<parameter_list>)

Enclosed in parentheses (), the parameter list defines the inputs the function accepts. * The parentheses are mandatory, even if the function takes no parameters. * If there are parameters, they are a comma-separated list. * Each parameter must have an explicit name and type, following the name: type pattern we established for let statements. This consistency is a hallmark of good language design.

Examples of parameter lists: * No parameters: () * One parameter: (count: int) * Multiple parameters: (x: float, y: float, is_active: bool)

Semantic Rule: Inside the function’s body, parameters act as new, immutable local variables that are initialized with the values passed by the caller. They behave exactly like variables declared with let.

4. The Return Type -> <return_type>

This part of the function signature specifies the type of the value that the function will return. * The -> (an arrow composed of a dash and a greater-than sign) is a clear syntactic marker that separates the parameter list from the return type. * The <return_type> must be one of our defined primitive types: int, float, or bool.

5. The Function Body { ... }

Enclosed in curly braces {}, the body contains the sequence of statements that are executed when the function is called. * This block defines a new scope. Variables declared with let inside the function body only exist within that function and cannot be accessed from the outside. * The logic inside can include let bindings, if/else expressions, and other function calls.

6. The return Statement

To send a value back to the caller, the function body must use the return keyword. * Semantic Rule: A return statement immediately terminates the execution of the function. * Type-Checking Rule: The type of the <expression> that follows the return keyword must match the <return_type> specified in the function’s signature. This is a critical rule that our compiler’s semantic analyzer will enforce. For example, a function declared with -> int cannot return true;.

Design Decision: Functions That Return Nothing

What about functions that just perform an action but don’t return a value, like a hypothetical print_score function? These are often called “procedures” or “void functions” in other languages.

For SimpliLang, we will adopt a simple and elegant rule: * If a function does not return a value, the entire -> <return_type> part is simply omitted.

// A function that takes a parameter but returns no value.
fn print_score(score: int) {
    // Logic to print the score would go here.
    // No 'return' statement with a value is allowed.
}

Our compiler will enforce this: a function with no return type specified cannot have a return statement that includes a value. An empty return; could be allowed in the future to exit early, but for now, we’ll keep it simple: the function just runs to the closing brace }.

Putting It All Together: Examples

Here are some complete function definitions using the syntax we’ve designed:

// Takes two integers and returns their sum.
fn add(a: int, b: int) -> int {
    return a + b;
}

// Takes two floats and returns a boolean indicating if the first is larger.
fn is_greater(x: float, y: float) -> bool {
    let result: bool = x > y;
    return result;
}

// Takes no parameters and returns a fixed integer value.
fn get_start_code() -> int {
    return 42;
}

// A more complex function using an if/else expression.
fn max(a: int, b: int) -> int {
    let result: int = if a > b {
        a
    } else {
        b
    };
    return result;
}

This design provides a robust, type-safe, and expressive way to define reusable logic in SimpliLang, forming the backbone of any non-trivial program.

Next Steps

We have now designed a perfect syntax for defining functions. But a function definition is useless on its own; we need a way to execute it. The next logical task is to design the syntax for function calls, which will allow us to invoke these named blocks of code and use their results.

Further Reading

Design the Syntax and Semantics for Function Calls

Mục tiêu: Define the syntax and semantic rules for invoking functions in the SimpliLang programming language. This task focuses on treating function calls as expressions and specifies the compile-time checks for existence, arity, and type compatibility between arguments and parameters.


In the previous task, we designed a comprehensive syntax for defining functions, giving SimpliLang a powerful tool for abstraction and code reuse. A function definition, however, is like a blueprint for a machine; it’s a detailed plan, but it doesn’t do any work on its own. To bring that blueprint to life, we need a mechanism to build and run the machine. In programming, this is known as a function call (or invocation).

This task focuses on designing the syntax and, more importantly, the semantic rules for calling the functions you’ve defined. This is the final piece of the puzzle that allows different parts of your program to communicate and work together.

The Power of a Call: Function Calls as Expressions

Just as we decided that if/else would be an expression that produces a value, we will make the same powerful design choice for function calls. In SimpliLang, a function call is an expression.

This means that whenever you call a function that returns a value, the call itself evaluates to that value. This allows you to seamlessly integrate function calls into any part of the language where a value is expected:

  • On the right side of a let binding: let x: int = get_value();
  • As part of an arithmetic expression: let y: int = 10 + get_value();
  • As the condition for an if expression: if is_ready() { ... }
  • As an argument to another function call: process_data(get_value());

This design choice leads to more concise, readable, and functional-style code.

Anatomy of a SimpliLang Function Call

The syntax for calling a function is designed to be simple, clean, and unambiguous. It consists of the function’s name followed by a list of arguments in parentheses.

<function_name>(<argument_list>)

Let’s break down each component:

1. The <function_name>

This is the identifier of the function you wish to call. It must exactly match the name of a function that has been previously defined.

2. The Argument List (<argument_list>)

This is a comma-separated list of expressions enclosed in parentheses (). The values these expressions evaluate to are passed into the function to initialize its parameters.

  • Parentheses are Mandatory: You must always include parentheses, even when calling a function that takes no parameters (e.g., get_start_code()). This is a crucial syntactic rule that distinguishes a function call (which executes code and produces a value) from a variable access (which just retrieves a stored value). It prevents ambiguity.
  • Arguments are Expressions: Each argument in the list is not just a literal or a variable; it can be any valid SimpliLang expression. For example, add(5 * 2, some_var / 2) is a valid function call. The expressions for each argument are evaluated before the function is called, and their resulting values are then passed to the function’s parameters.

The Semantic Rules: The Compiler’s Contract

While the syntax is simple, the real power of a statically-typed language lies in the strict semantic rules that the compiler enforces at compile time. For a function call to be considered valid in SimpliLang, it must satisfy the following contract:

  1. Existence Check: The function being called must exist. An attempt to call undefined_function() will result in a compile-time error.
  2. Arity Check: The number of arguments provided in the call must exactly match the number of parameters in the function’s definition. “Arity” is the term for the number of arguments a function takes. If fn add(a: int, b: int) is defined, calls like add(10) or add(10, 20, 30) are arity mismatch errors.
  3. Type Compatibility Check: The type of each argument expression must be compatible with the type of the corresponding parameter in the function’s signature. This check is performed for each argument in order.
    • Given fn is_greater(x: float, y: float) -> bool { ... }
    • The call is_greater(3.14, 9.81) is valid.
    • The call is_greater(10, 20) is invalid. 10 and 20 are int literals, but the function expects float parameters. This is a type error our compiler will catch.
  4. Result Type: The type of the entire function call expression is the return type declared in the function’s signature.
    • If fn add(...) -> int, then the expression add(1, 2) has the type int.
    • If a function has no declared return type (a “void” function like fn print_score(...)), then a call to it is a statement, not an expression. It performs an action but produces no value. Attempting to use it as a value, like let x: int = print_score(100);, will be a type error.

Putting It All Together: Examples

Let’s see how function calls integrate with the other features we’ve designed, using the example functions from the previous task.

// Defining our functions first.
fn add(a: int, b: int) -> int {
    return a + b;
}

fn get_start_code() -> int {
    return 42;
}

fn is_greater(x: float, y: float) -> bool {
    return x > y;
}

// Now, using them with function calls.

// Simple call used in a let binding.
let initial_score: int = get_start_code(); // The expression get_start_code() has type int.

// Using variables and expressions as arguments.
let player_score: int = 100;
let final_score: int = add(player_score, 15); // final_score is now 115.

// Using a function call within an 'if' condition.
let player_height: float = 1.8;
let required_height: float = 1.9;

if is_greater(player_height, required_height) {
    // This block is not executed.
    // The expression is_greater(...) has type bool.
}

// A type error that the compiler would catch:
// let invalid_sum: int = add(10, false); // Error: Expected second argument of type 'int', but found 'bool'.

This comprehensive design for function calls completes the core set of features for SimpliLang, providing a solid foundation for writing structured and meaningful programs.

Next Steps

We have now designed all the core building blocks of SimpliLang: primitive types, keywords, variable declarations, operators with precedence, if/else control flow, and function definitions/calls. We have a strong mental model and a set of rules for how the language should work. The next critical step is to formalize this design into a structured grammar, using a notation like EBNF (Extended Backus-Naur Form). This formal grammar will serve as the definitive blueprint for writing our parser.

Further Reading

Define the Formal Grammar for SimpliLang using EBNF

Mục tiêu: Consolidate all language design decisions for SimpliLang into a single, unambiguous, and formal specification using Extended Backus-Naur Form (EBNF). This grammar will define the complete syntax, including operator precedence, and serve as the blueprint for the parser.


Excellent work! Over the last several tasks, you have acted as a language designer, making crucial decisions about SimpliLang’s features, syntax, and semantics. You’ve defined types, keywords, variable bindings, operators, and control flow. These decisions currently exist as a collection of rules and examples. To build a robust parser, we must now consolidate all of this into a single, unambiguous, and formal specification.

This is the purpose of a formal grammar. It is the constitution of your language—the ultimate source of truth that precisely describes what constitutes a valid SimpliLang program. For this, we will use a standard notation called EBNF (Extended Backus-Naur Form).

What is EBNF?

EBNF is a formal way to describe the syntax of a language. It defines a set of production rules, where each rule names a piece of the language’s syntax (a non-terminal symbol) and defines how it can be constructed from other non-terminals and raw text (terminal symbols). The grammar we create will be the direct blueprint for the parser we will build in a later step.

Here is the EBNF notation we’ll use:

Notation Meaning Example
rule = ...; rule is defined as the expression on the right. digit = "0" | "1" | ...;
"text" A literal terminal symbol (e.g., a keyword or punctuation). let_keyword = "let";
| “Or”, separates alternative possibilities. boolean = "true" | "false";
[ ... ] The enclosed part is optional (zero or one time). optional_else = [ "else" ... ];
{ ... } The enclosed part can repeat zero or more times. statements = { statement };
( ... ) Groups expressions together. grouped = "(" expression ")";

The Formal Grammar of SimpliLang

Let’s build the grammar from the top down, starting with an entire program and progressively defining each smaller piece, grounding each rule in the design decisions you’ve already made.

Top-Level Structure

A SimpliLang program is, at its core, just a collection of function definitions.

program = { function_definition };

A function_definition follows the exact structure we designed, including the optional return type. The body of the function is a block.

function_definition = "fn" identifier "(" [ parameter_list ] ")" [ "->" type ] block;
parameter_list      = parameter { "," parameter };
parameter           = identifier ":" type;
type                = "int" | "float" | "bool";
  • parameter_list is defined as one parameter followed by zero or more comma-separated parameters.
  • A parameter reuses our identifier: type syntax.

Statements and Blocks

A block is a sequence of statements enclosed in curly braces. A statement can be a let binding, a return statement, or an expression followed by a semicolon (for cases like calling a function that doesn’t return a value).

block     = "{" { statement } "}";
statement = let_statement | return_statement | expression_statement;

let_statement        = "let" identifier ":" type "=" expression ";";
return_statement     = "return" expression ";";
expression_statement = expression ";";
  • These rules perfectly match our design for immutable, explicitly typed, and initialized variables (let_statement) and function returns (return_statement).

Expressions and Operator Precedence

This is the most critical part of the grammar. We must encode the operator precedence rules you designed directly into the structure of the rules. We do this by creating a chain of rules, where each level of the chain corresponds to a level of precedence.

The lowest precedence operators are defined first.

expression = comparison;

A comparison expression handles the non-associative comparison operators. The [ comp_operator term ] part ensures that you can only have one comparison, preventing a < b < c.

comparison = term [ comp_operator term ];
comp_operator = "==" | "!=" | "<" | ">";

Next up the precedence chain is term, which handles the left-associative + and - operators. The { ... } allows for chaining like a + b - c.

term = factor { ( "+" | "-" ) factor };

Higher still is factor, handling the left-associative * and / operators.

factor = unary { ( "*" | "/" ) unary };

The unary rule is for operators that apply to a single operand, like negation. Let’s add the unary minus (-) for arithmetic negation.

unary = [ "-" ] primary;

Finally, primary represents the highest-precedence expressions—the atomic units of your language.

primary = integer_literal
        | float_literal
        | boolean_literal
        | identifier
        | function_call
        | if_expression
        | "(" expression ")";
  • This rule defines that a primary expression can be a literal value, a variable name, a function call, an if/else expression, or another whole expression wrapped in parentheses to override precedence.

Function Calls and if Expressions

We need to define the syntax for function_call and if_expression, which we previously designed.

function_call = identifier "(" [ argument_list ] ")";
argument_list = expression { "," expression };

if_expression = "if" expression block [ "else" block ];
  • Notice how argument_list is a list of expressions. This means you can pass complex calculations as arguments, like add(5 * 2, max(a, b)).
  • The [ "else" block ] makes the else part of an if expression optional, just as we specified.

Lexical Definitions (Terminals)

The final step is to define what the terminal symbols like identifier and integer_literal actually look like. We can describe these using regular expression-like syntax.

identifier      = ( "a"..."z" | "A"..."Z" | "_" ) { "a"..."z" | "A"..."Z" | "_" | "0"..."9" };
integer_literal = { "0"..."9" };
float_literal   = { "0"..."9" } "." { "0"..."9" };
boolean_literal = "true" | "false";

The Complete EBNF Grammar for SimpliLang

Here are all the rules collected in one place. This document is the definitive specification for the syntax of SimpliLang version 1.

(* --- Top-Level --- *)
program = { function_definition };

function_definition = "fn" identifier "(" [ parameter_list ] ")" [ "->" type ] block;
parameter_list      = parameter { "," parameter };
parameter           = identifier ":" type;
type                = "int" | "float" | "bool";

(* --- Statements --- *)
block                = "{" { statement } "}";
statement            = let_statement | return_statement | expression_statement;
let_statement        = "let" identifier ":" type "=" expression ";";
return_statement     = "return" expression ";";
expression_statement = expression ";";

(* --- Expressions (with Precedence) --- *)
expression    = comparison;
comparison    = term [ ( "==" | "!=" | "<" | ">" ) term ];
term          = factor { ( "+" | "-" ) factor };
factor        = unary { ( "*" | "/" ) unary };
unary         = [ "-" ] primary;
primary       = integer_literal
              | float_literal
              | boolean_literal
              | identifier
              | function_call
              | if_expression
              | "(" expression ")";

(* --- Compound Expressions --- *)
function_call = identifier "(" [ argument_list ] ")";
argument_list = expression { "," expression };
if_expression = "if" expression block [ "else" block ];

(* --- Lexical Terminals --- *)
identifier      = ( "a"..."z" | "A"..."Z" | "_" ) { "a"..."z" | "A"..."Z" | "_" | "0"..."9" };
integer_literal = { "0"..."9" };
float_literal   = { "0"..."9" } "." { "0"..."9" };
boolean_literal = "true" | "false";

Next Steps

You have successfully translated your language design into a formal grammar. This is a monumental step that bridges the gap between abstract design and concrete implementation. With this blueprint in hand, the path to writing a parser is now crystal clear.

Your very next task is to use this grammar as a guide to write a few sample .spl (SimpliLang) programs. These files will be invaluable for testing your lexer and parser as you build them.

Further Reading

Create SimpliLang Sample Programs

Mục tiêu: Write a suite of sample programs for the new SimpliLang language. These files will serve as the initial test cases for the compiler’s lexer, parser, and semantic analyzer, embodying a test-driven development approach.


With the formal EBNF grammar now complete, you have created the official constitution for SimpliLang. This is a monumental step that transforms a collection of ideas into a formal specification. However, a constitution is just a document; to see it in action, you need a society that lives by its rules. For our compiler, that means creating the first-ever SimpliLang programs.

Before we can write a single line of code to parse our language, we must first have some examples of the language to parse. This task is all about bringing your EBNF blueprint to life by writing a few sample programs. These files will become our invaluable test cases for every subsequent step of the compiler’s development, ensuring that the code we write matches the language we designed.

The Role of Test-Driven Development in Compilers

Think of these .spl files as your compiler’s first and most important unit tests.

  • When you build the lexer in the next step, you will feed it these files to verify that it produces the correct stream of tokens.
  • When you build the parser, you will use that token stream to check if it constructs the correct Abstract Syntax Tree (AST).
  • When you build the semantic analyzer, you’ll check if it correctly type-checks these programs and catches any deliberate errors we might add later.

Having a solid set of examples from the very beginning makes the development process faster, more reliable, and less prone to regressions.

Creating a Home for Your Examples

First, let’s create a dedicated directory to store our sample programs. In your project’s root directory (the one containing src/ and Cargo.toml), create a new directory named examples.

mkdir examples

Now, let’s create a few files inside this new directory. Each file will test a different aspect of the language you’ve designed.


Example 1: The Simplest Program

Let’s start with the most basic valid program: a main function that simply returns an integer. This will be our “Hello, World!” equivalent.

Create a file named examples/basic_return.spl and add the following content:

// examples/basic_return.spl

// This program demonstrates the most fundamental structure of a SimpliLang program,
// corresponding to our EBNF rule: `program = { function_definition };`
//
// We will follow the C-style convention where a function named `main` serves
// as the program's entry point.

// This line matches the `function_definition` rule:
// `fn` identifier `()` `->` type block
fn main() -> int {
    // This line matches the `return_statement` rule:
    // `return` expression `;`
    return 0;
}

This simple file is perfect for our first test. It verifies the parser can handle a minimal function definition and a return statement with a literal value.

Example 2: Variables and Arithmetic

Next, let’s create a program that tests variable declarations and our operator precedence rules.

Create a file named examples/arithmetic.spl with the following code:

// examples/arithmetic.spl

// This program tests `let` statements and the precedence of arithmetic operators.
fn main() -> int {
    // This `let_statement` rule is tested here:
    // `let` identifier `:` type `=` expression `;`
    let a: int = 10;

    // This expression tests our precedence rules. According to our grammar:
    // `term = factor { ("+"|"-") factor };` and `factor = unary { ("*"|"/") unary };`
    // This means `*` has higher precedence than `+`.
    // The expression should be parsed as `20 * 2` first, then `+ 5`.
    let b: int = 20 * 2 + 5; // b should be 45.

    let initial_value: float = 1.25;
    let is_done: bool = false;

    // A simple addition using previously declared variables.
    let result: int = a + b; // result should be 10 + 45 = 55.

    return result;
}

This file will be crucial for verifying that your parser correctly interprets expressions according to the precedence levels (* before +) you designed in the EBNF grammar.

Example 3: Control Flow with if/else

Now, let’s test our if/else construct, particularly its powerful nature as an expression.

Create a file named examples/conditionals.spl with this content:

// examples/conditionals.spl

// This program showcases the use of `if/else` for control flow.

// We define a helper function to test a common pattern.
fn is_positive(num: int) -> bool {
    // This expression tests our `comparison` rule.
    return num > 0;
}

fn main() -> int {
    let score: int = -10;

    // Here, `if` is used as an expression. The entire `if/else` block
    // evaluates to a value that is then assigned to `final_score`.
    // This matches the `if_expression` rule in our grammar.
    let final_score: int = if is_positive(score) {
        score // This block returns `score`.
    } else {
        0   // This block returns `0`.
    };

    return final_score; // Should return 0.
}

This example is excellent for testing function calls (is_positive(score)), comparison operators (>), and the expression-based if/else.

Example 4: A Complete Program with Multiple Functions

Finally, let’s create a more comprehensive example that ties everything together: multiple functions that call each other.

Create a file named examples/full_program.spl:

// examples/full_program.spl

// A more complete example showing multiple functions working together.

// A helper function to find the greater of two numbers.
// This tests function definition with multiple parameters.
fn max(a: int, b: int) -> int {
    if a > b {
        return a;
    } else {
        return b;
    }
}

// Our main program entry point.
fn main() -> int {
    let x: int = 100;
    let y: int = 250;

    // This tests a `function_call` with variables as arguments.
    // The result of the call is used to initialize `max_val`.
    let max_val: int = max(x, y);

    return max_val; // Should return 250.
}

This file will be a great end-to-end test for the parser, ensuring it can handle a complete program with multiple top-level definitions and nested calls.

Next Steps

Congratulations! You have successfully concluded the design phase for SimpliLang. You have a formal EBNF grammar that acts as your blueprint and now a suite of sample programs that serve as your test cases. This is a tremendous achievement that sets you up for success in the implementation phase.

We now shift from being language designers to being compiler engineers. In the next step, you will begin building the first major component of the compiler: the Lexer. The lexer’s job will be to read these .spl files you just created and break them down into a stream of discrete tokens, paving the way for the parser.

Further Reading

  • Test-Driven Development (TDD): A methodology where you write tests before you write the actual code. Our sample files are the first step in this process.
  • Compiler Test Suites: To see how professional, large-scale compilers are tested, you can explore their test suites. This can be very insightful.
  • Creating a Language Corpus: The collection of sample files you’ve created is the beginning of a “corpus” for your language, which is essential for its development and evolution.