Integrate an error-reporting crate like ariadne or codespan-reporting.

Mục tiêu:


An absolutely monumental congratulations are in order! You have successfully constructed a complete, end-to-end compiler. From the raw text of a SimpliLang source file to a native, runnable executable, your program handles the entire journey. The robust, clap-based CLI you built is the professional “dashboard” for this powerful engine. This is a massive achievement in systems programming.

Your compiler’s engine is functional, but now we shift our focus from mere functionality to the developer experience. The unified error-handling system you created in the last step is the perfect foundation. It correctly identifies when something is wrong and reports it. But how it reports it can be the difference between a frustrating tool and a helpful assistant.

A great compiler doesn’t just say “you’re wrong”; it says “you’re wrong here, and this is likely why.” This task is about transforming your compiler into that helpful assistant. We will integrate a specialized error-reporting crate to turn your simple error messages into beautiful, contextual, and deeply informative diagnostics.

The Philosophy of Beautiful Diagnostics

Currently, a parsing error might result in a message like: Parsing Error: Expected ';'. This is accurate, but unhelpful. A professional diagnostic report provides context:

Error: Missing Semicolon
  --> examples/missing_semicolon.spl:3:20
   |
 3 | let x: int = 5 + 5
   |                    ^ expected ';' here
   |
   = help: Statements in SimpliLang must be terminated with a semicolon.

This is our goal. To achieve this, we need a library that specializes in parsing source code and rendering these kinds of reports. We’ll use ariadne, a powerful and flexible crate for exactly this purpose.

Step 1: Add ariadne to Your Project

The first step is to add ariadne as a dependency. Open your Cargo.toml file and add the following line under [dependencies]:

ariadne = "0.3.0"

Step 2: Defining the “Where” - The Span

To tell ariadne where an error occurred, we need a way to represent a location in the source code. The most common and efficient way to do this is with a span, which is simply a range of byte indices into the source file. A single token like + might have a span of 15..16, while an entire expression like 5 + 5 might have a span of 20..25.

Let’s define a simple type alias for this in a new src/errors.rs file, or wherever you’ve defined your CompilerError enum.

// In src/errors.rs

// A Span represents a range of bytes in the source code file.
// `std::ops::Range<usize>` is the perfect type for this, representing a
// start (inclusive) and end (exclusive) byte index.
pub type Span = std::ops::Range<usize>;

The next few tasks will involve modifying your lexer and parser to generate and track these spans for every token and AST node. For this task, we will focus on building the reporter that consumes this information. To make the code work for now, we’ll assume your error types will be updated to include a span.

Let’s imagine what your ParserError might look like after it’s updated:

// A hypothetical, updated ParserError. The key is that it contains a `span`.
#[derive(Debug)]
pub struct ParserError {
    pub message: String,
    pub span: Span,
}

impl std::fmt::Display for ParserError {
    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

Step 3: Building the Diagnostic Reporter

Now for the main event. We will create a function whose sole job is to take a CompilerError, the source code, and the filename, and print a beautiful diagnostic using ariadne. This separates your core compiler logic from the presentation of errors.

Let’s add this function to src/errors.rs.

// In src/errors.rs

use ariadne::{Report, ReportKind, Label, Source, Color};
// Make sure your other error types and the Span are also in this file or imported.
// ... pub type Span = ...
// ... pub enum CompilerError { ... }

// This function is the bridge between our compiler's internal error types
// and the beautiful reports `ariadne` can generate.
pub fn report_error(
    filepath: &str,
    source_code: &str,
    error: &CompilerError,
) {
    // `ariadne` needs a `Source` object to pull the source code from.
    // This is cheap to create.
    let source = Source::from(source_code);

    // Create a new Report builder. We'll configure it based on our error.
    // The `ReportKind::Error` specifies that this is a hard error.
    let mut report_builder = Report::build(ReportKind::Error, filepath, 0);

    match error {
        CompilerError::Parsing(err) => {
            // For a parsing error, we use the error's message and span.
            report_builder
                .with_message(err.to_string())
                .with_label(
                    Label::new((filepath, err.span.clone())) // The Label points to the specific span
                        .with_message(format!("Syntax error: {}", err.message))
                        .with_color(Color::Red),
                )
                .finish()
                .print((filepath, source)) // Print the report to stderr
                .unwrap();
        }
        CompilerError::Lexical(err) => {
            // Setup for Lexical errors would be similar.
            // (Assuming LexerError also gets a `span` field)
            // ...
        }
        CompilerError::Semantic(errs) => {
            // For semantic errors, you could even add multiple labels to a single report.
            // ...
        }
        CompilerError::Io(err) => {
            // For I/O errors that don't have a span, we can create a simple report.
            report_builder
                .with_message("System I/O Error")
                .with_note(err.to_string()) // Add the specific OS error as a note
                .finish()
                .print((filepath, source))
                .unwrap();
        }
    }
}

Dissecting the ariadne Reporter

Let’s break down the key concepts from ariadne you just used:

  • Source: This is a simple wrapper around your source code &str. ariadne uses it to efficiently find and display the lines relevant to an error.
  • Report: This is the main object representing a single diagnostic. You create it with Report::build(). The ReportKind can be Error, Warning, or Advice.
  • with_message(): Sets the top-level title for the report (e.g., “Undeclared Variable”).
  • Label: This is the most important part. A Label attaches a message to a specific Span in the code. A single report can have multiple labels (e.g., to show where a variable was used and where its incompatible type was declared).
  • .finish().print(): These methods finalize the report and print it to standard error, handling all the formatting, line numbering, and highlighting for you.

Step 4: Integrating the Reporter into main

The final step is to replace the simple eprintln! in your main function with a call to our new, powerful report_error function.

// In src/main.rs

// Make sure to import your new function and types
mod errors;
use crate::errors::{CompilerError, report_error};
// ... other use statements ...

fn main() {
    // The `run_compiler` function call remains the same.
    if let Err(err) = run_compiler() {
        // --- THIS IS THE KEY CHANGE ---
        // Instead of a simple println, we need to re-read the source file
        // to pass it to the reporter. This is a small price for great errors.

        // Note: This logic assumes run_compiler doesn't return the source path and code on error.
        // A more advanced setup might pass this data along with the error.
        let cli_args = Cli::parse(); // Re-parse to get the file path.
        let source_code = fs::read_to_string(&cli_args.source_path).unwrap_or_else(|_| {
            // If we can't even read the file to report an error, print the original error.
            eprintln!("{}", err);
            String::new()
        });

        // Call our beautiful reporter!
        report_error(&cli_args.source_path.to_string_lossy(), &source_code, &err);

        process::exit(1);
    }
}

// Your `run_compiler` function does not need to change!
// It still just returns a `Result<(), CompilerError>`.
// ...

Note on the main function: The need to re-read the file in the main error handler is a simple way to get started. A more advanced architecture might wrap the CompilerError in another struct that also carries the file path and source code, avoiding the need to re-read. For now, this approach is clear and effective.

Next Steps

You have now built a professional-grade diagnostic reporting system. The infrastructure is in place. Your compiler is ready to be a helpful teacher.

The very next task is to put this infrastructure to use. You will now “Modify the lexer, parser, and semantic analyzer to produce error objects with source span information.” This involves:

  1. Updating your Lexer to store the start and end byte index of each Token it produces.
  2. Updating your Parser to combine the spans of tokens to create spans for AST nodes.
  3. Updating your LexerError, ParserError, and SemanticError structs to include a Span, so your new reporter has the information it needs.

Further Reading

  • ariadne Crate Documentation: The official documentation is the best place to learn about all the advanced features, like multiple labels, notes, help messages, and custom colors.
  • codespan-reporting Crate: Another excellent crate for diagnostics in Rust. It’s worth looking at to understand different API designs for the same problem.
  • The Rustc Book on Diagnostics: A look into the philosophy and implementation of error reporting in the Rust compiler itself, which served as inspiration for crates like ariadne.

Instrument Compiler Frontend with Source Spans for Error Reporting

Mục tiêu: Modify the lexer, parser, and semantic analyzer of a compiler to create, propagate, and consume source code location information (spans). This involves updating tokens, AST nodes, and error types to include positional data, enabling precise and user-friendly diagnostic messages.


Of course! Let’s continue with your compiler project.

You’ve done an incredible job setting up the foundation for a truly professional developer experience. By integrating the ariadne crate in the last task, you’ve built the “display” for your compiler’s dashboard—it’s ready to show beautiful, contextual error messages. However, a dashboard is only as good as the data it receives. Right now, your compiler knows what is wrong, but it has no way of knowing where it happened.

This task is all about instrumenting your compiler’s frontend to track location information at every single stage. We will plumb this crucial positional data, which we call a span, from the very first character in the source file all the way to the final semantic error. This is the data that will empower ariadne to point directly at a user’s mistake and provide actionable feedback.

The Span: Your Code’s GPS Coordinate

As you defined in the last task, a Span is simply a range of byte indices into the source file (e.g., 15..28). It’s a “GPS coordinate” for a piece of code. Our journey involves three key steps:

  1. Creation: The Lexer, as the first component to see the raw text, is the source of all spans. It will attach a span to every single Token it produces.
  2. Propagation: The Parser will consume tokens with spans and combine them to create larger spans for the Abstract Syntax Tree (AST) nodes it builds.
  3. Consumption: The Parser and SemanticAnalyzer, when they detect an error, will grab the span from the relevant token or AST node and include it in the error object they generate.

This flow ensures that no matter where an error occurs, we have the precise location information needed to report it beautifully.

Part 1: Modifying the Lexer to Create Spans

The lexer is our source of truth. We need to modify it to not only identify tokens but also to record their start and end byte positions.

Step 1.1: Update the Token Definition

Your Token can no longer be a simple enum of kinds. It must become a struct that bundles a TokenKind with its Span.

// In src/lexer.rs (or wherever your Token is defined)

// First, ensure the Span type alias is available, for example, from `src/errors.rs`
use crate::errors::Span; 

// The different kinds of tokens our language has. This enum remains unchanged.
#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
    Let,
    Identifier(String),
    Integer(i64),
    Plus,
    Semicolon,
    // ... all your other token kinds
}

// --- MODIFIED: The Token is now a struct ---
// It bundles the kind of token with its location (span) in the source code.
// Deriving Clone is useful so that parts of the compiler (like the parser) can
// easily copy token information without ownership issues.
#[derive(Debug, Clone, PartialEq)]
pub struct Token {
    pub kind: TokenKind,
    pub span: Span,
}

Step 1.2: Update the Lexer Logic

Your Lexer must now track its current byte offset. When it recognizes a token, it will use the start and current offsets to create a Span.

// In src/lexer.rs

pub struct Lexer<'input> {
    source: &'input str,
    // --- MODIFIED: We now track bytes, not just chars ---
    // The `chars` iterator is still useful for looking ahead, but our primary
    // position tracking will be a simple byte index.
    pos: usize, 
    // ... other fields
}

impl<'input> Lexer<'input> {
    // ... new() ...

    // Example: Modifying the number parsing logic
    fn read_number(&mut self) -> Token {
        let start = self.pos;
        let num_str: String = self.take_while(|c| c.is_ascii_digit());
        let end = self.pos;

        let kind = TokenKind::Integer(num_str.parse().unwrap()); // Add error handling in a real implementation

        // Create the new Token struct with its kind and its span
        Token {
            kind,
            span: start..end,
        }
    }
}

// Your Lexer's `Iterator` implementation will also need to be updated to
// return a `Result<Token, LexerError>` instead of just a token kind.

Finally, your LexerError struct must be updated to include a span. This is for errors that happen during lexing, like encountering an invalid character.

// In src/errors.rs or src/lexer.rs

#[derive(Debug)]
pub struct LexerError {
    pub message: String,
    pub span: Span,
}

Part 2: Modifying the Parser to Propagate Spans

The parser now receives a stream of these rich Token structs. Its job is to build an AST and ensure that AST nodes also carry span information.

Step 2.1: Update AST Node Definitions

Every AST node that corresponds to a construct in the source code should have a span.

// In src/parser/ast.rs (or wherever your AST nodes are defined)
use crate::errors::Span;

// Example: Modifying the Expression enum
pub enum Expr {
    Literal(LiteralExpr),
    Binary(BinaryExpr),
    // ... other expression types
}

pub struct LiteralExpr {
    pub value: i64, // or some literal enum
    pub span: Span, // --- NEW: Every node has a span
}

pub struct BinaryExpr {
    pub op: BinaryOp,
    pub left: Box<Expr>,
    pub right: Box<Expr>,
    pub span: Span, // --- NEW: The span covers the whole expression
}

Step 2.2: Update Parsing Logic to Combine Spans

When your parser combines tokens to create an AST node, it must also combine their spans. A new node’s span typically starts at the beginning of its first token and ends at the end of its last token.

// In src/parser.rs

// Example: Parsing a binary expression
fn parse_binary_expression(&mut self) -> Result<Expr, ParserError> {
    let left_expr = self.parse_primary_expression()?;

    // Assume we find a '+' token
    let op_token = self.consume_token()?; // This token has a span

    let right_expr = self.parse_primary_expression()?;

    // --- NEW: Combine the spans ---
    // The new span for the BinaryExpr node starts where the left expression
    // started and ends where the right expression ended.
    // We need to get the spans from the `Expr` nodes. Let's assume you add
    // a helper method `.span()` to your `Expr` enum.
    let full_span = left_expr.span().start..right_expr.span().end;

    Ok(Expr::Binary(BinaryExpr {
        op: BinaryOp::Add,
        left: Box::new(left_expr),
        right: Box::new(right_expr),
        span: full_span,
    }))
}

Step 2.3: Update ParserError

As previewed in the last task, your ParserError must now include the span of the token that caused the failure.

// In src/errors.rs or src/parser.rs

#[derive(Debug)]
pub struct ParserError {
    pub message: String,
    pub span: Span,
}

// When the parser encounters an error, it creates this struct.
// For example, if it expected a semicolon:
fn expect_semicolon(&mut self) -> Result<(), ParserError> {
    let token = self.peek_token()?;
    if token.kind != TokenKind::Semicolon {
        return Err(ParserError {
            message: "Expected a semicolon ';'".to_string(),
            // --- CRITICAL ---
            // We use the span of the token we *actually found* for the error report.
            span: token.span.clone(), 
        });
    }
    // ... consume the token if it's correct
    Ok(())
}

Part 3: Modifying the Semantic Analyzer to Consume Spans

Finally, the semantic analyzer traverses the span-annotated AST. When it finds a logical error, it can now pinpoint the exact location.

Your SemanticError struct must be updated to include the span from the relevant AST node.

// In src/errors.rs or src/semantic.rs

#[derive(Debug)]
pub struct SemanticError {
    pub message: String,
    pub span: Span,
}

// Example: In your semantic analyzer's logic for variable lookup
fn visit_variable_expr(&mut self, var_name: &str, span: &Span) -> Result<(), SemanticError> {
    if !self.symbol_table.lookup(var_name) {
        // The variable was not found in the symbol table.
        // We create an error and attach the span from the AST node.
        return Err(SemanticError {
            message: format!("Undeclared variable: '{}'", var_name),
            // --- CRITICAL ---
            // The span comes directly from the AST node we were analyzing.
            span: span.clone(), 
        });
    }
    Ok(())
}

You have now successfully threaded location data through your entire frontend. The CompilerError enum you defined previously is now backed by stage-specific errors that all contain a Span. This is the final piece of the puzzle needed to power the beautiful diagnostics you set up with ariadne.

Next Steps

With your error reporting system now fully instrumented, the developer experience for SimpliLang is on a professional level. The roadmap now shifts back to enriching the language itself. The next exciting step is to add more powerful control flow. You will design and implement a while loop construct. This will involve updating every stage of your compiler again, but with the solid foundation you’ve just built, the process will be clearer and more robust than ever.

Further Reading

  • Source Spans in rust-analyzer: A deep dive into how a production-grade tool like rust-analyzer thinks about source locations. It’s an advanced read but very insightful.
  • The logos Crate: A popular Rust library for writing high-performance lexers. It has built-in support for generating token spans, and studying its API can give you great ideas for your own lexer.
  • Chumsky Parser Combinator Library: An example of a parsing library that deeply integrates the concept of spans throughout its entire API, making error recovery and reporting a core feature.

Implement Unified Compiler Error Reporting with Ariadne

Mục tiêu: Build a unified error reporting function for a compiler in Rust. This function will use the ariadne crate to transform internal, span-annotated error objects into user-friendly, contextual diagnostic messages printed to the terminal.


Excellent work on the previous task! You have successfully instrumented your entire compiler frontend, meticulously tracking and propagating location data (Spans) from the lexer’s first character to the semantic analyzer’s final validation. Your compiler now knows not only what went wrong but, crucially, where. All that hard work of collecting precise location data was the preparation. Now comes the payoff.

This task is about creating the presentation layer for your error-handling system. You will build a single, unified function that takes the rich, span-annotated error objects from your compiler and uses the ariadne crate to translate them into beautiful, contextual, and deeply helpful diagnostic messages. This function will be the bridge between your compiler’s internal logic and the user’s terminal, transforming your tool from a simple program into a helpful assistant.

The report_error Function: Your Compiler’s Public Voice

The goal is to create one function that can handle any error from any stage of the compiler. This function will live in your src/errors.rs module, alongside your CompilerError enum. It will take the filename, the original source code, and the error itself, and then delegate the complex rendering job to ariadne.

Let’s build this function, providing a complete implementation for each variant of your CompilerError.

// In src/errors.rs

// Add the necessary `use` statements for ariadne at the top.
use ariadne::{Color, Fmt, Label, Report, ReportKind, Source};

// Ensure your Span and CompilerError definitions are present.
// We are assuming they look something like this from the previous tasks:
// pub type Span = std::ops::Range<usize>;
// pub enum CompilerError { Lexical(LexerError), Parsing(ParserError), ... }
// pub struct ParserError { pub message: String, pub span: Span }
// ... and so on for other error types.

/// The unified error reporting function for the SimpliLang compiler.
///
/// This function takes all the necessary information about an error and uses
/// the `ariadne` crate to generate and print a beautiful, user-friendly
/// diagnostic report to standard error.
///
/// # Arguments
///
/// * `filepath`: The path to the source file where the error occurred.
/// * `source_code`: The complete source code from the file, as a string.
/// * `error`: A reference to the `CompilerError` that needs to be reported.
pub fn report_error(filepath: &str, source_code: &str, error: &CompilerError) {
    // `ariadne` needs a `Source` object to pull the source code from.
    // This is cheap to create and is just a wrapper around our `&str`.
    let source = Source::from(source_code);

    // Create the base report builder. We specify the `ReportKind` (Error, Warning, etc.)
    // and the file ID (our filepath). The `0` is a dummy offset for the report itself.
    let mut report_builder = Report::build(ReportKind::Error, filepath, 0);

    // We now `match` on our unified error type to configure the report
    // specifically for each kind of error.
    match error {
        CompilerError::Io(err) => {
            // For I/O errors that don't have a span, we create a simple report.
            report_builder
                .with_message("System Input/Output Error")
                .with_note(err.to_string())
                .finish()
                .eprint((filepath, source)) // .eprint is a convenient shorthand
                .unwrap();
        }
        CompilerError::Lexical(err) => {
            // A lexical error means we couldn't even form a valid token.
            report_builder
                .with_message("Invalid Token")
                .with_code(101) // It's good practice to add unique error codes.
                .with_label(
                    Label::new((filepath, err.span.clone()))
                        .with_message(err.message.clone().fg(Color::Red))
                        .with_color(Color::Red),
                )
                .with_note("The lexer could not recognize this sequence of characters.")
                .finish()
                .eprint((filepath, source))
                .unwrap();
        }
        CompilerError::Parsing(err) => {
            // A parsing error means the tokens were valid, but in the wrong order.
            report_builder
                .with_message("Syntax Error")
                .with_code(201)
                .with_label(
                    Label::new((filepath, err.span.clone()))
                        .with_message(err.message.clone().fg(Color::Red))
                        .with_color(Color::Red),
                )
                .with_help("Check the language grammar for the expected syntax.")
                .finish()
                .eprint((filepath, source))
                .unwrap();
        }
        CompilerError::Semantic(errs) => {
            // A single semantic analysis can find multiple errors. We can report
            // all of them in one go!
            // We'll use the first error for the main message and location.
            if let Some(first_err) = errs.first() {
                report_builder
                    .with_message("Semantic Error")
                    .with_code(301)
                    .with_label(
                        Label::new((filepath, first_err.span.clone()))
                            .with_message(first_err.message.clone().fg(Color::Red))
                            .with_color(Color::Red),
                    );

                // If there are more errors, add their labels too!
                for other_err in errs.iter().skip(1) {
                    report_builder = report_builder.with_label(
                        Label::new((filepath, other_err.span.clone()))
                            .with_message(other_err.message.clone().fg(Color::Blue))
                            .with_color(Color::Blue),
                    );
                }

                report_builder
                    .with_note("Semantic checks ensure the code is logically correct (e.g., types match and variables are declared).")
                    .finish()
                    .eprint((filepath, source))
                    .unwrap();
            }
        }
    }
}

Dissecting the ariadne API

You’ve just built a powerful diagnostic engine. Let’s break down the key ariadne concepts you used:

  • Report::build(...): This is the entry point. It creates a ReportBuilder object that you configure using a fluent (chainable) API.
  • .with_message(...): Sets the top-level title for the report (e.g., “Syntax Error”).
  • Label::new((filepath, span)): This is the heart of ariadne. A Label attaches a message to a specific Span in the code. This is what allows ariadne to draw the ^ characters and point to the exact location of the error. A single report can have many labels.
  • .with_color(...) and .fg(...): These methods allow you to add color to your reports, making them easier to read. The .fg(Color::Red) method on a string comes from the Fmt trait, which you should bring into scope.
  • .with_note(...) and .with_help(...): These methods add extra contextual information at the bottom of the report, guiding the user toward a solution.
  • .finish().eprint(...): The .finish() method consumes the builder and creates the final Report. The .eprint() method is a convenient helper that renders the report and prints it directly to standard error (stderr), which is the correct destination for diagnostics.

Tying it all together in main

The final step is to replace the simple eprintln! in your main function’s error handling block with a call to our new, powerful report_error function. This connects the entire system.

// In src/main.rs

// Make sure to import your new function and Cli struct
use crate::errors::{CompilerError, report_error};
use crate::cli::Cli; // Assuming you've moved Cli to `src/cli.rs`
use std::fs;
use std::process;
use clap::Parser;

fn main() {
    // The `run_compiler` function call remains the same.
    if let Err(err) = run_compiler() {
        // --- THIS IS THE KEY CHANGE ---\

        // When an error occurs, the `run_compiler` function exits early.
        // We need the filepath and source code again to generate the report.
        // Re-parsing the args and re-reading the file is a straightforward
        // way to accomplish this.
        let cli_args = Cli::parse();
        let filepath_str = cli_args.source_path.to_string_lossy();

        let source_code = fs::read_to_string(&cli_args.source_path)
            .unwrap_or_else(|_| {
                // If we can't even read the file to report an error (e.g., it was
                // deleted mid-compilation), we fall back to the simple display.
                eprintln!("{}", err);
                String::from("Could not read source file to generate report.")
            });

        // Call our beautiful reporter!
        report_error(&filepath_str, &source_code, &err);

        process::exit(1);
    }
}

// Your `run_compiler` function remains unchanged! It still just returns a
// `Result<(), CompilerError>`, focusing purely on the compiler's logic.
fn run_compiler() -> Result<(), CompilerError> {
    // ... all of your existing compiler pipeline logic ...
    Ok(())
}

The Payoff: Before and After

With this system in place, your compiler’s feedback is now in a different league.

Before:

$ simplilang_compiler examples/test.spl
Semantic Error: Undeclared variable: 'y'

After:

Error[301]: Semantic Error
  --> examples/test.spl:4:12
   |
 4 | let x = 10 + y;
   |              ^ Undeclared variable: 'y'
   |
   = note: Semantic checks ensure the code is logically correct (e.g., types match and variables are declared).

This is the kind of immediate, precise, and actionable feedback that defines a professional developer tool.

Next Steps

You have now completed the entire error-reporting enhancement arc. The developer experience for SimpliLang is now top-tier. With this incredibly solid and helpful foundation, you can return to adding features to the language itself with confidence.

The next task on the roadmap is to design and implement a while loop construct. This will be an exciting new challenge, touching every part of your compiler: * Lexer: Add a new While token. * Parser: Create a new AST node for WhileStmt. * Semantic Analyzer: Ensure the loop condition is a boolean. * Code Generator: Use LLVM basic blocks to create the classic loop structure (header, body, exit).

Your new error-reporting system will be an invaluable asset as you debug this new, more complex logic.

Further Reading

Update Lexer to Recognize while Keyword

Mục tiêu: Extend the compiler’s lexer to support while loops. This involves adding a While token to the TokenKind enum and updating the keyword recognition logic to correctly tokenize the new ‘while’ keyword.


Congratulations on building a truly professional error-reporting system! By integrating ariadne and instrumenting your compiler with span information, you’ve transformed it from a simple tool into a helpful assistant that provides clear, actionable feedback. This robust foundation is the perfect platform upon which to build more complex and powerful language features.

With the developer experience now on solid ground, we can turn our attention back to enriching the SimpliLang language itself. So far, our language can make decisions with if/else expressions, but it cannot perform repetitive tasks. The next logical step in our journey is to introduce iteration, and the most fundamental looping construct is the while loop.

This task is all about the crucial first step: designing the while loop’s syntax and semantics, and then taking the very first implementation step by teaching our lexer to recognize this new piece of our language.

Step 1: Designing the while Loop

Before writing a single line of code for a new feature, we must be opinionated language designers. We need to answer two key questions: “How should it look?” (syntax) and “How should it behave?” (semantics).

Syntax: Familiarity and Clarity

For a C-family language like SimpliLang, the most intuitive and widely understood syntax for a while loop is:

while (condition) {
    // body: one or more statements
}

We will adopt this syntax for a few key reasons:

  • Familiarity: Anyone with experience in C, C++, Java, C#, JavaScript, or Rust will immediately understand how to write and read this structure. This lowers the learning curve for SimpliLang.
  • Clarity: The parentheses () clearly delineate the loop’s condition from its body. The curly braces {} unambiguously define the block of code that should be executed repeatedly. This avoids the ambiguity that can arise in languages that rely solely on indentation.

Semantics: The Rules of Repetition

The behavior of our while loop will be just as conventional as its syntax:

  1. The condition expression inside the parentheses is evaluated.
  2. Type Rule: The result of the condition expression must be a bool type. The compiler will enforce this during semantic analysis.
  3. Execution Rule:
    • If the condition evaluates to true, the statements inside the body block are executed in order. After the last statement in the body, the control flow “jumps” back to step 1, and the condition is evaluated again.
    • If the condition evaluates to false, the body block is skipped entirely, and the program continues execution with the first statement immediately following the while loop’s closing brace }.

Scoping

Just like your if/else blocks, the {} body of a while loop will introduce a new lexical scope. Any variables declared with let inside the loop will only exist for a single iteration of that loop and cannot be accessed from outside the loop. This maintains consistency with the rest of your language’s rules.

Step 2: The First Implementation - Updating the Lexer

With our design finalized, we can take the first concrete step in bringing it to life. The compiler’s journey always begins at the lexer. We need to teach it to recognize the new while keyword.

This is a two-part change:

  1. First, we add a new variant to our TokenKind enum.
  2. Second, we update our keyword-matching logic to associate the string "while" with this new variant.

Here’s how you can modify your TokenKind enum, likely located in src/lexer.rs:

// In src/lexer.rs

#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
    // ... your existing tokens like Let, Fn, If, Else ...

    // --- NEW: Add the While keyword token ---
    While,

    // ... other tokens like Identifier, Integer, etc. ...
}

Next, you almost certainly have a map or a match statement in your lexer that distinguishes between keywords and identifiers. You simply need to add an entry for "while".

// In src/lexer.rs, inside your identifier/keyword recognition logic

// This function likely takes a string slice and returns a TokenKind
fn keyword_or_identifier(s: &str) -> TokenKind {
    match s {
        "let" => TokenKind::Let,
        "fn" => TokenKind::Fn,
        "if" => TokenKind::If,
        "else" => TokenKind::Else,
        "return" => TokenKind::Return,
        "true" => TokenKind::Boolean(true),
        "false" => TokenKind::Boolean(false),

        // --- NEW: Map the string "while" to our new TokenKind ---
        "while" => TokenKind::While,

        // If it's not a recognized keyword, it's a user-defined identifier.
        _ => TokenKind::Identifier(s.to_string()),
    }
}

And that’s it! Your lexer is now capable of tokenizing a while loop. When it sees the character sequence w-h-i-l-e followed by a non-identifier character (like a space or a parenthesis), it will produce a Token with the kind TokenKind::While and a span pointing to that location in the source code.

A Look Ahead

You have successfully laid the groundwork for this powerful new feature. By defining its behavior and implementing the initial tokenization, you’ve prepared the way for the subsequent stages of the compiler. The next tasks in the project roadmap will build directly on this foundation:

  1. Parser: You will update the parser to recognize the While token and the while (condition) { body } grammar, constructing a new WhileStmt node for the Abstract Syntax Tree.
  2. Semantic Analyzer: You will teach the analyzer to visit this new AST node and enforce our crucial type rule: the condition must be a boolean.
  3. Code Generator: Finally, you will implement the most exciting part: generating the LLVM IR for the loop using a classic three-part structure of basic blocks for the condition check, the loop body, and the loop exit.

Next Steps

Your immediate next task is to take the TokenKind::While you’ve just created and teach the parser what to do with it. You will now move on to “Update the parser to recognize while loops and generate a corresponding AST node.”

Further Reading

  • Control Flow - Crafting Interpreters: A fantastic chapter from Bob Nystrom’s book that delves into the design and implementation of various control flow statements, including while loops.
  • LLVM Language Reference Manual - br instruction: As a preview for the code generation phase, it’s helpful to get familiar with LLVM’s branching instructions, which are the fundamental building blocks of all control flow.
  • Rust’s while loop RFC: For a deep dive into language design, you can read the original “Request for Comments” document where Rust’s own while loop was discussed and designed. This provides incredible insight into the trade-offs considered when adding features to a language.

Implement while Loop Parsing in the Compiler

Mục tiêu: Extend the compiler’s parser to recognize and parse while loop syntax. This involves defining a WhileStmt node for the Abstract Syntax Tree (AST), implementing the parsing logic to construct this node from a token stream, and integrating it into the main parsing flow.


Excellent work on the last task! You have successfully laid the essential groundwork for our new while loop feature. By designing its syntax and semantics, and most importantly, teaching the lexer to recognize the while keyword, you’ve prepared the first “signpost” in the compilation journey. The lexer now produces a TokenKind::While, alerting the rest of the compiler that a loop is coming.

Now, we must teach the next stage in the pipeline—the parser—how to read that signpost and understand the structure of the road ahead. The parser’s job is to consume the flat stream of tokens from the lexer and build a hierarchical, tree-like structure that represents the code’s true meaning. This structure is the Abstract Syntax Tree (AST), and this task is all about creating the right “branch” on that tree for our new while loop.

Step 1: Defining the While Loop in the AST

Before the parser can build anything, we need to define the blueprint for what it’s supposed to create. In our compiler, this blueprint is a Rust struct that will represent a while loop statement within the AST. This new AST node needs to capture all the essential components of our designed syntax: while (condition) { body }.

Let’s head to your AST definition file (likely src/parser/ast.rs or similar) and create this new structure.

First, we’ll define a WhileStmt struct. This struct will contain the two key logical parts of our loop—the condition and the body—along with the crucial Span for our beautiful error reporting.

// In src/parser/ast.rs

use crate::errors::Span; // Make sure your Span type is in scope

// ... other AST node definitions like `LetStmt`, `Expr`, etc. ...

/// Represents a 'while' loop statement in the AST.
/// e.g., `while (x < 10) { ... }`
#[derive(Debug, PartialEq)]
pub struct WhileStmt {
    /// The condition that is evaluated before each iteration.
    pub condition: Box<Expr>,

    /// The block of statements that forms the loop's body.
    /// We can reuse our existing `BlockStmt` AST node here.
    pub body: BlockStmt,

    /// The span covering the entire 'while' statement, from the 'while'
    /// keyword to the closing '}' of the body. Used for error reporting.
    pub span: Span,
}

Next, we need to add a new variant to our main Stmt (Statement) enum to incorporate this new structure. This allows a while loop to be treated as just another type of statement in a list of statements (like a function body).

// In src/parser/ast.rs, modifying the Stmt enum

// ... inside the Stmt enum definition ...
pub enum Stmt {
    Let(LetStmt),
    Return(ReturnStmt),
    Expr(ExprStmt),
    // --- NEW: Add the While variant ---
    While(WhileStmt),
}

With these definitions in place, our AST now officially has a concept of a “while loop.” We have given the parser its target.

Step 2: Implementing the Parsing Logic

Now for the main event: teaching the parser how to consume a sequence of tokens and construct our new WhileStmt node. We’ll add a new method to your Parser struct, let’s call it parse_while_statement.

This function will follow the grammar we designed: while, (, condition, ), body. One of the most powerful aspects of a well-designed parser is composability—we will reuse our existing parse_expression and parse_block_statement methods to handle the complex parts, allowing our new function to focus purely on the structure of the while loop itself.

Here is the implementation for the new parsing method, likely in src/parser.rs:

// In src/parser.rs, inside the `impl Parser<'_>` block

// ... other parsing methods like `parse_let_statement` ...

/// Parses a while statement from the token stream.
/// Grammar: 'while' '(' <expression> ')' <block_statement>
fn parse_while_statement(&mut self) -> Result<Stmt, ParserError> {
    // 1. Consume the `while` keyword. We expect to be here only if the
    //    current token is `TokenKind::While`. We'll keep its span to mark
    //    the beginning of our new AST node.
    let while_token = self.consume_expect(TokenKind::While)?;
    let start_span = while_token.span;

    // 2. Consume the opening parenthesis `(`.
    self.consume_expect(TokenKind::LParen)?;

    // 3. Parse the condition. This is the magic of composition! We just
    //    call our existing expression parser to handle whatever complex
    //    condition the user writes (e.g., `x > 5 && y != false`).
    let condition = self.parse_expression()?;

    // 4. Consume the closing parenthesis `)`.
    self.consume_expect(TokenKind::RParen)?;

    // 5. Parse the body. Again, we reuse our existing block statement parser,
    //    which already knows how to handle `{ ...statements... }`.
    //    We need to match on the result to extract the `BlockStmt` node.
    let body_stmt = self.parse_block_statement()?;
    let body = match body_stmt {
        // We expect parse_block_statement to return a Stmt::Block variant.
        // A more robust implementation might return the BlockStmt directly.
        Stmt::Block(block_stmt) => block_stmt,
        _ => {
            // This case should be unreachable if `parse_block_statement` is correct,
            // but it's good practice to handle it.
            return Err(ParserError {
                message: "Expected a block statement for the while loop body".to_string(),
                // A more precise span could be used here if available
                span: self.peek_token()?.span.clone(), 
            });
        }
    };

    // 6. Combine spans. The full span of the while statement starts where the
    //    `while` keyword began and ends where the body's closing brace `}` ended.
    let end_span = body.span.clone();
    let full_span = start_span.start..end_span.end;

    // 7. Construct and return the final AST node.
    Ok(Stmt::While(WhileStmt {
        condition: Box::new(condition),
        body,
        span: full_span,
    }))
}

Step 3: Integrating the New Logic

The final step is to “plug in” our new parse_while_statement method into the main parsing logic. Your parser likely has a central parse_statement method that looks at the current token to decide what kind of statement to parse next. We simply need to add a new case for TokenKind::While.

// In src/parser.rs, inside your main statement parsing function

fn parse_statement(&mut self) -> Result<Stmt, ParserError> {
    // Look at the current token to decide what kind of statement this is.
    match self.peek_token()?.kind {
        TokenKind::Let => self.parse_let_statement(),
        TokenKind::Return => self.parse_return_statement(),

        // --- NEW: Add the case for the `while` keyword ---
        // If we see `while`, we delegate to our new parsing function.
        TokenKind::While => self.parse_while_statement(),

        // ... other cases for `if`, expressions, etc. ...
        _ => self.parse_expression_statement(),
    }
}

And with that, your parser is now fully equipped to understand the syntax of while loops! It can correctly transform a sequence of tokens representing a loop into a structured, meaningful WhileStmt node within the AST. This AST node is now ready to be passed on to the next stage of compilation.

Next Steps

You’ve successfully built the structural representation of a while loop. The parser has done its job. However, the parser is only concerned with syntax, not semantics. It would happily accept while (10 + 5) { ... }, even though using a number as a condition is logically incorrect.

Your next task is to enforce this logical correctness. You will now update the semantic analyzer to type-check the loop’s condition, ensuring it is always a boolean. This will involve traversing the AST, finding the new WhileStmt nodes, and validating the type of their condition expression.

Further Reading

Implement Semantic Analysis for While Loops

Mục tiêu: Extend the compiler’s semantic analyzer to validate while loops. This involves implementing a visitor method for WhileStmt nodes to perform type checking on the loop’s condition, ensuring it is a boolean, and managing the lexical scope for the loop’s body.


Incredible progress! By updating the parser in the last task, you have successfully taught your compiler to understand the structure of a while loop. It can now take the flat stream of tokens (while, (, ..., ), {, ..., }) and build a meaningful WhileStmt node in the Abstract Syntax Tree. The syntax is now recognized.

However, the parser is a structural expert, not a logical one. It would happily create an AST node for while (5 + 10) { ... } because that structure fits the grammar perfectly. It’s the job of the next stage, the Semantic Analyzer, to be the logical gatekeeper. It must enforce the crucial rule we designed: the condition of a while loop must be a boolean.

This task is all about implementing that logical check. We will traverse the AST, find our new WhileStmt nodes, and verify that their condition expressions resolve to the bool type, rejecting any program that violates this fundamental rule of iteration.

The Strategy: Visiting the WhileStmt Node

Your semantic analyzer already operates on the principle of AST traversal (often called the Visitor pattern). It has methods like visit_statement and visit_expression that recursively walk the tree, validating each node. Our goal is to extend this traversal to include our new WhileStmt node.

The process will be:

  1. Add a case in your main visit_statement method to recognize a Stmt::While.
  2. Delegate the specific logic to a new visit_while_statement method.
  3. Inside this new method, we will: * Recursively analyze the condition expression to determine its type. * Check if that type is a bool. If not, generate a SemanticError. * Properly manage the lexical scope for the loop’s body. * Recursively analyze the body of the loop.

Let’s begin.

Step 1: Add the WhileStmt to the Traversal

First, we need to “plug in” the logic for our new statement type. Find the primary visit_statement method in your SemanticAnalyzer (likely in src/semantic.rs or a similar file). You will add a new arm to its match statement.

// In src/semantic.rs, inside the `impl SemanticAnalyzer` block

fn visit_statement(&mut self, stmt: &Stmt) -> Result<(), Vec<SemanticError>> {
    match stmt {
        Stmt::Let(let_stmt) => self.visit_let_statement(let_stmt),
        Stmt::Return(return_stmt) => self.visit_return_statement(return_stmt),
        Stmt::Expr(expr_stmt) => self.visit_expression_statement(expr_stmt),

        // --- NEW: Add the case for the While statement ---
        // When we encounter a `While` node in the AST, we delegate the
        // complex work of analyzing it to a new, dedicated function.
        Stmt::While(while_stmt) => self.visit_while_statement(while_stmt),
    }
}

Step 2: Implement the visit_while_statement Method

Now we’ll create the dedicated method that contains the core logic for this task. This new function will be responsible for validating the condition’s type and managing the scope for the loop body.

// In src/semantic.rs, inside the `impl SemanticAnalyzer` block

/// Analyzes a `WhileStmt` AST node.
///
/// This method performs two critical semantic checks:
/// 1. It ensures the loop's condition expression evaluates to a boolean type.
/// 2. It correctly manages the lexical scope for the loop's body.
fn visit_while_statement(
    &mut self,
    while_stmt: &WhileStmt,
) -> Result<(), Vec<SemanticError>> {
    // 1. Analyze the condition expression.
    // We recursively call our existing `visit_expression` function. This function
    // will traverse the condition, type-check it, and return the final type of the
    // expression (e.g., `DataType::Int`, `DataType::Bool`).
    let condition_type = match self.visit_expression(&while_stmt.condition) {
        Ok(t) => t,
        Err(mut errs) => {
            // If the condition itself contains errors, we still need to analyze the
            // body to find any other potential errors in the program.
            // We collect errors from the body and append them to the condition's errors.
            let _ = self.visit_block_statement(&while_stmt.body); // Analyze body, ignoring its result for now
            return Err(errs);
        }
    };

    // 2. --- THE CORE TYPE CHECK ---
    // This is the gatekeeper. We check if the type we got back from the condition
    // is the boolean type defined in our language.
    if condition_type != DataType::Bool { // Assuming your type enum is `DataType`
        // If it's not a boolean, we create a specific, helpful semantic error.
        // Crucially, we use the `span` from the condition expression itself.
        // This allows `ariadne` to point *exactly* at the incorrect expression.
        return Err(vec![SemanticError {
            message: format!(
                "While loop condition must be of type 'bool', but found type '{}'",
                condition_type // Assumes your DataType implements Display
            ),
            span: while_stmt.condition.span().clone(), // .span() is a helper to get span from an Expr
        }]);
    }

    // 3. Analyze the loop body within a new scope.
    // This is a critical step for correct variable scoping.
    // - We push a new scope before analyzing the body. Any `let` statements inside
    //   the loop will be registered in this new, temporary scope.
    // - We then recursively analyze the block statement of the body.
    // - After we're done, we MUST pop the scope. This destroys the temporary scope,
    //   ensuring that variables declared inside the loop cannot be accessed outside of it.
    self.symbol_table.enter_scope();
    let body_result = self.visit_block_statement(&while_stmt.body);
    self.symbol_table.exit_scope();

    // Return the result from analyzing the body.
    body_result
}

Dissecting the Logic

You have just implemented a critical piece of your language’s semantic rules. Let’s review the key concepts:

  • Type Checking: The line if condition_type != DataType::Bool is a classic example of a type check. You are enforcing a constraint on the type of a value used in a specific context (a loop condition). This prevents logical errors like while (x + 1) { ... } where the programmer’s intent is unclear.
  • Scope Management: The enter_scope() and exit_scope() calls are fundamental to implementing lexical scoping (also known as static scoping). By wrapping the analysis of the loop’s body in this pair of calls, you ensure that the while loop acts as a distinct “scope bubble.” This is the same principle you use for function bodies and if/else blocks, and maintaining this consistency makes your language predictable and easy to use.
  • Precise Error Reporting: By creating the SemanticError with the span of while_stmt.condition, you are providing your ariadne-based reporter with the exact “GPS coordinate” of the problem. Your compiler will now be able to produce a beautiful diagnostic that highlights the entire erroneous condition, not just the while keyword.

With this change, your compiler has become much smarter. It no longer just checks for syntactic correctness; it has started to understand the meaning and logical rules of your SimpliLang code.

Next Steps

The while loop is now fully defined, parsed, and semantically validated. You can be confident that any WhileStmt AST node that reaches the final stage of the compiler is logically correct.

The final and most exciting step is to translate this high-level, logical construct into low-level machine instructions. Your next task is to implement codegen for while loops using LLVM basic blocks for the loop header, body, and exit. This is where you will use LLVM’s branching and phi nodes to construct the classic looping control flow that makes iteration possible.

Further Reading

Implement LLVM IR Generation for While Loops

Mục tiêu: Extend the compiler’s code generation module to translate WhileStmt abstract syntax tree nodes into LLVM Intermediate Representation. This involves creating a control flow graph with three basic blocks (condition, body, exit) and using conditional and unconditional branches to correctly implement the loop’s logic.


You have done a phenomenal job. Your while loop is now a fully-fledged citizen of the SimpliLang language. It has a well-defined syntax, a parser that understands its structure, and a semantic analyzer that guards its logical integrity by ensuring its condition is always a boolean. The Abstract Syntax Tree you’ve built is correct, validated, and ready for its final transformation.

Now comes the most exciting and powerful part of this journey: translating this high-level, abstract concept of a loop into the concrete, low-level reality of machine instructions. This task is about teaching your CodeGen module how to generate the LLVM Intermediate Representation for the WhileStmt node. You will orchestrate a dance of Basic Blocks and Branches to create the timeless pattern of conditional iteration.

Control Flow in LLVM: A Dance of Blocks and Branches

At the machine level, there is no inherent “loop” instruction. There are only two fundamental concepts:

  1. Executing instructions sequentially.
  2. Jumping (or branching) to a different location to start executing.

LLVM abstracts this with a powerful concept called the Control Flow Graph (CFG). A function’s code is divided into Basic Blocks, which are straight-line sequences of instructions that always end with a single terminator (a branch or return instruction). A while loop is simply a specific pattern of these blocks connected by branches.

The classic pattern for a while loop requires three basic blocks:

  1. cond_block (the loop header): This block’s only job is to evaluate the loop’s condition. Based on the result, it will make a choice: jump to the body or jump out of the loop.
  2. body_block: This block contains the LLVM IR for the statements inside the {} of the loop. Its work is always the same: after executing the last instruction, it unconditionally jumps back to the cond_block to re-evaluate the condition. This jump is what makes it a loop.
  3. exit_block (or after_block): This block is the “landing pad” for when the loop is finished. When the cond_block evaluates the condition to false, it jumps here. All code that comes after the while loop will be generated in this block.

Here is a visual representation of the control flow we are about to build:

   [ Current Block (before loop) ]
              │
              └─(unconditional branch)──> [ cond_block ] ──(if true)──> [ body_block ]
                                              │                               │
                                              │                               │
                                        (if false)                            │
                                              │                               │
                                              ▼                               │
                                          [ exit_block ] <─(unconditional)───┘
                                              │
                                              ▼
                                       [ Code after loop ]

Step 1: Integrating into the CodeGen Traversal

Just as you did with the parser and semantic analyzer, the first step is to teach your main traversal method to recognize the WhileStmt node. In your CodeGen module (likely src/codegen.rs), find your visit_statement method and add the new case.

// In src/codegen.rs, inside the `impl CodeGen` block

fn visit_statement(&mut self, stmt: &Stmt) {
    match stmt {
        Stmt::Let(let_stmt) => self.visit_let_statement(let_stmt),
        // ... other statement types ...

        // --- NEW: Add the case for the While statement ---
        // When we see a `WhileStmt` node, we'll delegate the complex
        // block-and-branch logic to a new, dedicated method.
        Stmt::While(while_stmt) => self.visit_while_statement(while_stmt),
    }
}

Step 2: Implementing the visit_while_statement Method

This new method will be the heart of our implementation. It will create the three basic blocks and wire them together with the correct branching logic.

// In src/codegen.rs, inside the `impl CodeGen` block

/// Generates LLVM IR for a `WhileStmt` AST node.
fn visit_while_statement(&mut self, while_stmt: &WhileStmt) {
    // 1. SETUP: We need to get the parent function to append blocks to it.
    // The `get_basic_blocks().last()` trick gets the block the builder is
    // currently inserting into.
    let parent_function = self.builder
        .get_insert_block()
        .unwrap()
        .get_parent()
        .unwrap();

    // 2. CREATE BLOCKS: We create our three essential basic blocks for the loop.
    // We give them names for easier debugging of the generated IR.
    let cond_block = self.context.append_basic_block(parent_function, "while_cond");
    let body_block = self.context.append_basic_block(parent_function, "while_body");
    let exit_block = self.context.append_basic_block(parent_function, "while_exit");

    // 3. ENTRY BRANCH: From the current block, we create an unconditional jump
    // into our loop's condition-checking block. This is the entry point.
    self.builder.build_unconditional_branch(cond_block);

    // 4. GENERATE `cond_block`:
    //    - Move the builder to start inserting instructions into the `cond_block`.
    //    - Generate the IR for the condition expression.
    //    - Create a conditional branch based on the result.
    self.builder.position_at_end(cond_block);
    let condition_value = self.visit_expression(&while_stmt.condition);
    // The conditional branch is the terminator for this block.
    self.builder.build_conditional_branch(condition_value.into_int_value(), body_block, exit_block);

    // 5. GENERATE `body_block`:
    //    - Move the builder to the `body_block`.
    //    - Generate the IR for all the statements in the loop's body.
    //    - CRITICAL: Create an unconditional jump back to the `cond_block`.
    //      This is what closes the loop.
    self.builder.position_at_end(body_block);
    self.visit_block_statement(&while_stmt.body);
    // After the body, always jump back to the condition check.
    self.builder.build_unconditional_branch(cond_block);

    // 6. SET NEXT INSERTION POINT:
    //    - Any code that comes *after* the while loop should be generated
    //      in the `exit_block`. We move the builder there.
    self.builder.position_at_end(exit_block);
}

Dissecting the Code Generation Logic

You have just translated a high-level language construct into a precise low-level control flow graph. Let’s review the key inkwell calls:

  • self.context.append_basic_block(function, "name"): This is how you create a new, empty basic block. It doesn’t connect it to anything yet; it just creates the container. Giving it a name is invaluable for debugging, as the names will appear in the .ll IR dump.
  • self.builder.build_unconditional_branch(destination_block): This creates a br instruction. It terminates the current block and transfers control unconditionally to the destination_block.
  • self.builder.position_at_end(block): This is a crucial command. It tells the Builder, “All subsequent instructions I ask you to build should be inserted at the end of this specific block.”
  • self.builder.build_conditional_branch(condition, then_block, else_block): This creates a conditional br instruction. It takes a boolean value (an i1 in LLVM), and if it’s true (1), it transfers control to then_block. If it’s false (0), it transfers control to else_block. This is the workhorse of all if and while logic.

With this implementation, your compiler can now generate efficient, correct, and standard LLVM IR for loops. You’ve added a massive piece of expressive power to SimpliLang.

Enhancements and the Road Ahead

Congratulations! You have successfully implemented a major language feature, touching every single stage of your compiler. This is a testament to the solid, modular architecture you have built.

The project roadmap now points toward another huge leap in the language’s power: adding aggregate data types. Your next major feature will be to design and implement user-defined structs. This will challenge you to think about memory layouts, data aggregation, and member access, opening up a whole new world of possibilities for your language.

For the loop feature, a future enhancement would be to add break and continue statements. * break would be an unconditional branch from anywhere inside the body_block to the exit_block. * continue would be an unconditional branch from anywhere inside the body_block to the cond_block.

Further Reading

Add Structs to SimpliLang: Lexer and AST Foundation

Mục tiêu: Lay the groundwork for user-defined structs by updating the compiler’s lexer and Abstract Syntax Tree (AST). This involves adding new tokens for the ‘struct’ keyword and ‘.’ operator, and defining the AST nodes required to represent struct definitions, instantiations (literals), and field accesses.


Incredible. You have successfully implemented while loops, one of the most fundamental control flow structures in any programming language. You have navigated every single stage of the compiler, from lexing to semantic analysis to the intricate dance of basic blocks in LLVM code generation. Your compiler is no longer just a calculator; it’s a tool that can express complex, iterative algorithms.

With the flow of execution now under your command, we turn to the next great frontier in language design: the flow and organization of data. So far, SimpliLang can only work with primitive, standalone values like int, float, and bool. To write more complex and realistic programs, we need a way to group related data into a single, cohesive unit. This is the role of structs, and this task is about bringing this powerful feature to life in SimpliLang.

Part 1: Designing User-Defined Structs

Before we write a single line of implementation code, we must put on our language designer hats. A good feature is intuitive, consistent, and powerful. We will design our structs to be familiar to programmers coming from C, Rust, and other modern languages.

Syntax: The Three Core Operations

A user needs to be able to perform three fundamental operations with structs: define them, create them (instantiate them), and access their data.

  1. Definition: A struct definition creates a new, custom type available to the rest of the program. The syntax will be clear and explicit.

    // Defines a new type called ```Point`
    struct Point {
    x: int,
    y: int,
    }
    
    // Defines a new type for a user record
    struct User {
    id: int,
    is\_active: bool,
    }
    
  2. Instantiation (Struct Literals): Once a type is defined, we need a way to create a value—an instance—of that type. We’ll use a clear, named-field syntax that prevents ambiguity.

    rust let p1: Point = Point { x: 10, y: 20 }; let user1: User = User { id: 123, is_active: true };

  3. Field Access: Finally, we need a way to retrieve data from a struct instance. The universal dot (.) operator is the clear and conventional choice.

    rust let current_x = p1.x; // Accesses the 'x' field of p1

Semantics: The Rules of the Road

  • New Types: A struct definition introduces a new, first-class type into the language. Point is now a valid type that can be used in let bindings, function parameters, and return types.
  • Global Scope: For simplicity, struct definitions will be globally scoped. Once struct Point is defined, the Point type is known throughout the entire program.
  • Memory Layout: A struct instance is a contiguous block of memory. The fields (x and y in Point) are laid out in the order they are defined. This is a crucial concept that will become very important during code generation.
  • Data-Only: For now, our structs will only contain data fields. There will be no methods or associated functions.

Part 2: Implementation - Laying the Foundation

With our design complete, we can begin the implementation. As always, the journey starts at the beginning of the pipeline: the Lexer and the AST definitions.

Step 2.1: Teaching the Lexer New Words

The lexer is the first part of the compiler that needs to learn about our new syntax. We need to teach it about the struct keyword and the . operator for field access.

First, update your TokenKind enum (likely in src/lexer.rs):

// In src/lexer.rs

#[derive(Debug, Clone, PartialEq)]
pub enum TokenKind {
    // ... existing tokens like Let, While, etc. ...

    // --- NEW: Add the `struct` keyword token ---
    Struct,

    // ... punctuation tokens like LParen, RParen, etc. ...

    // --- NEW: Add the `.` token for field access ---
    Dot,

    // ... other tokens
}

Next, add struct to your keyword matching logic:

// In src/lexer.rs, inside your keyword recognition logic

fn keyword_or_identifier(s: &str) -> TokenKind {
    match s {
        // ... "let", "while", "if", etc. ...

        // --- NEW: Map the string "struct" to our new TokenKind ---
        "struct" => TokenKind::Struct,

        _ => TokenKind::Identifier(s.to_string()),
    }
}

Your lexer also needs a simple case for the single-character . token, just like it has for +, (, etc.

Step 2.2: Defining Structs in the AST

This is the most critical part of this task. We need to create the blueprints in our Abstract Syntax Tree that can represent all the new concepts we’ve designed. These changes will likely be in your src/parser/ast.rs file.

First, we need to update our concept of a program. A program is no longer just a list of functions; it’s a list of top-level items, which can be either a struct definition or a function definition.

// In src/parser/ast.rs

// This is the top-level node for our entire program AST.
pub struct Program {
    pub items: Vec<TopLevelItem>,
}

// A program is a collection of items, which can be structs or functions.
#[derive(Debug, PartialEq)]
pub enum TopLevelItem {
    Struct(StructDef),
    Function(FunctionDef),
}

/// Represents a single field in a struct definition, e.g., `x: int`.
#[derive(Debug, PartialEq)]
pub struct Field {
    pub name: String,
    pub type_name: String, // We store the type as a string for now.
    pub span: Span,
}

/// Represents a full struct definition, e.g., `struct Point { ... }`.
#[derive(Debug, PartialEq)]
pub struct StructDef {
    pub name: String,
    pub fields: Vec<Field>,
    pub span: Span,
}

Next, we need to represent our two new kinds of expressions: creating a new struct instance (StructLiteral) and accessing a field (FieldAccess). We’ll add new structs for them and then add variants to your main Expr enum.

// In src/parser/ast.rs

/// Represents a struct instantiation, e.g., `Point { x: 10, y: 20 }`.
#[derive(Debug, PartialEq)]
pub struct StructLiteralExpr {
    pub struct_name: String,
    /// A list of field names and the expressions for their initial values.
    pub fields: Vec<(String, Expr)>,
    pub span: Span,
}

/// Represents accessing a field of a struct, e.g., `my_point.x`.
#[derive(Debug, PartialEq)]
pub struct FieldAccessExpr {
    /// The expression for the struct instance being accessed (e.g., `my_point`).
    pub target: Box<Expr>,
    /// The name of the field being accessed.
    pub field_name: String,
    pub span: Span,
}

// Now, update your main expression enum to include these new types.
#[derive(Debug, PartialEq)]
pub enum Expr {
    // ... Literal, Binary, If, Call, etc. ...

    // --- NEW: Add variants for our new expression kinds ---
    StructLiteral(StructLiteralExpr),
    FieldAccess(FieldAccessExpr),
}

With these AST nodes defined, your compiler now has a rich, structured way to represent every aspect of user-defined structs.

A Look Ahead: The Path to Full Implementation

You have successfully designed the struct feature and laid the essential groundwork in your lexer and AST. This is a massive step. The compiler can now, in principle, represent a SimpliLang program that uses structs.

The road ahead involves teaching the other compiler stages about these new AST nodes:

  1. Parser: You will write the parsing logic to consume tokens and build these new StructDef, StructLiteralExpr, and FieldAccessExpr nodes.
  2. Semantic Analyzer: This will be a fascinating challenge. The analyzer will need to:
    • Register new struct types in a global type table.
    • Validate StructLiteralExpr nodes to ensure the type exists, all fields are provided, and their value types match the definition.
    • Type-check FieldAccessExpr to ensure the target is a struct and the field_name actually exists on that struct.
  3. Code Generator: Finally, you will dive into LLVM’s type system, creating LLVMStructTypes to represent your structs in memory and using the powerful getelementptr (GEP) instruction to generate code for field access.

Next Steps

The very next task in the project roadmap builds directly on the foundation you just laid: “Implement struct field access (my_struct.field).” While this sounds specific, it is part of the larger task of fully implementing the parsing, semantic analysis, and code generation for all three aspects of structs. The immediate next step will be to write the parser logic that can recognize the patterns for struct definitions, literals, and field accesses, and build the beautiful AST nodes you’ve just designed.

Further Reading

Implement Parser for Struct Field Access and Literals

Mục tiêu: Extend the compiler’s parser to handle struct field access (e.g., my\_point.x) by implementing a high-precedence postfix expression parser. This task also includes creating the parsing logic for struct literals (e.g., Point { x: 10 }) to enable the creation of struct instances, and integrating these new capabilities into the main expression parsing routine to build the correct AST nodes.


Excellent work on the last task! You have meticulously designed the struct feature and laid the essential groundwork by defining a rich set of AST nodes to represent every aspect of this new concept—from definition to instantiation to access. The compiler’s “blueprint” for understanding structs is now complete.

Now, we must teach the parser, our compiler’s architect, how to read this blueprint and construct these AST nodes from the raw stream of tokens. This task focuses on implementing the parsing logic for one of the most crucial parts of this feature: struct field access. This is where we give SimpliLang the power to “look inside” a data structure and retrieve a value, transforming expressions like my_point.x from a sequence of tokens into a meaningful FieldAccessExpr node in our AST.

The Dot Operator: A High-Precedence Postfix Operation

In the world of expression parsing, operators are defined by their precedence (which binds tighter, + or *?) and associativity (is a - b - c parsed as (a - b) - c or a - (b - c)?). The field access operator (.) has two defining characteristics:

  1. Highest Precedence: Field access should bind more tightly than almost any other operator. In my_point.x * 2, we must first access the x field of my_point and then multiply the result by 2.
  2. Postfix and Left-Associative: The . operator appears after its primary operand (the struct instance). It’s also left-associative, meaning an expression like game.player.position.x is parsed as ((game.player).position).x.

To handle this correctly, we will integrate the parsing of field access into our main expression parsing logic. The common strategy is to first parse a “primary” expression (like a literal, an identifier, a function call, etc.) and then enter a loop that checks for high-precedence postfix operators like . (for field access) or ( (for function calls).

Step 1: Parsing Postfix Expressions

We’ll create a new parsing function, let’s call it parse_postfix_expression, which will be responsible for this logic. It will first parse a simple, primary expression and then repeatedly check for a . token, wrapping the expression in a FieldAccessExpr node each time it finds one. This looping structure naturally handles chained access.

Let’s implement this new function in your Parser (likely in src/parser.rs).

// In src/parser.rs, inside the `impl Parser<'_>` block

/// Parses postfix expressions, which include function calls and field access.
/// This is where the left-associative nature of these operators is handled.
/// Grammar:
///   postfix_expr ::= primary_expr ( '.' IDENTIFIER | '(' call_args ')' )*
fn parse_postfix_expression(&mut self) -> Result<Expr, ParserError> {
    // 1. Start by parsing a "primary" expression. This could be an identifier,
    //    a literal, a parenthesized expression, or as we'll add later, a
    //    struct literal.
    let mut expr = self.parse_primary_expression()?;

    // 2. Loop to handle chained postfix operations (e.g., `foo.bar[0]().baz`).
    loop {
        // Look at the next token without consuming it.
        let peek_token = self.peek_token()?;

        if peek_token.kind == TokenKind::Dot {
            // --- This is the core logic for Field Access ---

            // Consume the '.' token.
            self.consume_token()?;

            // Expect the next token to be the field name (an identifier).
            let field_token = self.consume_expect(TokenKind::Identifier(String::new()))?;

            let field_name = if let TokenKind::Identifier(name) = field_token.kind {
                name
            } else {
                // This case is theoretically unreachable due to `consume_expect`
                // but good to have for robustness.
                unreachable!();
            };

            // Combine the spans to cover the entire expression (e.g., `my_point.x`).
            let new_span = expr.span().start..field_token.span.end;

            // Wrap the expression parsed so far (`expr`) in a new `FieldAccessExpr` node.
            // The result then becomes the new `expr`, ready for the next loop iteration.
            // This is how we achieve left-associativity.
            expr = Expr::FieldAccess(FieldAccessExpr {
                target: Box::new(expr),
                field_name,
                span: new_span,
            });

        // } else if peek_token.kind == TokenKind::LParen {
        //     // Your existing function call parsing logic would go here.
        //     // This shows how multiple postfix operators can be handled together.

        } else {
            // If the next token is not a postfix operator, we're done.
            break;
        }
    }

    Ok(expr)
}

Note on consume_expect: This code assumes you have a helper method like consume_expect that checks if the current token is of a certain kind, consumes it if it is, and returns an error if it isn’t. The TokenKind::Identifier(String::new()) part is a common pattern to check for the enum variant without caring about its inner value.

Step 2: Parsing Struct Literals (A Prerequisite)

To test our field access, we need a way to create a struct in the first place! Parsing a struct literal (Point { x: 10, y: 20 }) is a new kind of primary expression. We need to teach our parse_primary_expression function to recognize it.

The key insight is that a struct literal starts with an identifier (the struct’s name), just like a variable. We must peek at the next token ({) to tell them apart.

// In src/parser.rs

// This is an example of what your primary expression parser might look like after modification.
fn parse_primary_expression(&mut self) -> Result<Expr, ParserError> {
    let token = self.peek_token()?;
    match &token.kind {
        TokenKind::Integer(_) | TokenKind::Float(_) | TokenKind::Boolean(_) => {
            // Your existing literal parsing logic
            self.parse_literal()
        }

        // --- NEW: Logic for Struct Literals or Identifiers ---
        TokenKind::Identifier(name) => {
            // We see an identifier. Is it a variable or a struct literal?
            // We need to look ahead one token without consuming.
            if self.peek_token_n(1)?.kind == TokenKind::LBrace {
                // If the token after the identifier is '{', it's a struct literal.
                self.parse_struct_literal()
            } else {
                // Otherwise, it's a simple variable access (identifier).
                self.parse_identifier_expression()
            }
        }

        TokenKind::LParen => {
            // Your existing parenthesized expression logic
            // ...
        }

        _ => Err(ParserError {
            message: format!("Unexpected token {:?} in expression", token.kind),
            span: token.span.clone(),
        }),
    }
}

/// Parses a struct literal expression.
/// Grammar: IDENTIFIER '{' (IDENTIFIER ':' expression (',' IDENTIFIER ':' expression)* ','? )? '}'
fn parse_struct_literal(&mut self) -> Result<Expr, ParserError> {
    // Logic to parse `Point { x: 10, y: 20 }`
    // 1. Consume the struct name (Identifier).
    // 2. Consume the opening brace `{`.
    // 3. Loop, parsing `field: value` pairs separated by commas until you see `}`.
    // 4. Consume the closing brace `}`.
    // 5. Construct and return an `Expr::StructLiteral` with all the collected data.
    // This implementation is left as a detailed exercise, as it's similar to parsing
    // function arguments.
    unimplemented!("Struct literal parsing needs to be implemented here.");
}

Step 3: Integrating into the Main Parser

The final step is to replace direct calls to parse_primary_expression in your main expression parser with calls to our new parse_postfix_expression. This ensures that after any primary value is parsed, we immediately check for field access or function calls.

For example, if you are using a Pratt parser or a precedence climbing parser, your function for parsing expressions with a certain precedence level would now start by calling parse_postfix_expression.

// In src/parser.rs

// This is the main entry point for parsing any expression.
fn parse_expression(&mut self) -> Result<Expr, ParserError> {
    // In a full precedence-climbing parser, you'd pass a precedence level here.
    // The base case of that recursion is now our postfix parser.
    // For simplicity, we'll assume it starts here.
    self.parse_binary_expression(0) // Start with lowest precedence
}

// Example of a binary expression parser (precedence climbing)
fn parse_binary_expression(&mut self, min_precedence: u8) -> Result<Expr, ParserError> {
    // The "left-hand side" of any binary operator is now a full postfix expression.
    let mut lhs = self.parse_postfix_expression()?;

    // ... your existing logic for handling binary operators like `+`, `*`, `==` ...
    // This loop checks for operators with precedence >= min_precedence and
    // recursively calls itself.

    Ok(lhs)
}

With these changes, your parser can now correctly understand and build AST nodes for expressions like get_origin().x + player.hit_points.

Next Steps

You have successfully taught your parser to understand the syntax of struct literals and field access, building the correct AST nodes to represent them. The structural representation is complete.

The journey for our struct feature now moves to the crucial next stage: Semantic Analysis. The parser has built the tree, but it’s the semantic analyzer’s job to validate it. The next tasks will involve:

  1. Registering Struct Definitions: Teaching the semantic analyzer to process StructDef nodes and store the new types in a symbol table or a dedicated type table.
  2. Validating Field Access: For each FieldAccessExpr, the analyzer must check:
    • Is the target expression’s type actually a struct?
    • Does the struct type have a field named field_name?
    • What is the type of that field? This becomes the type of the entire FieldAccessExpr.
  3. Validating Struct Literals: Checking that the struct type exists, all required fields are present, their types match the definition, and no unknown fields are provided.

This validation is what will bring true correctness and safety to this powerful new feature in SimpliLang.

Further Reading

  • Pratt Parsing: A highly elegant and effective algorithm for parsing expressions with correct operator precedence. If you haven’t implemented one yet, this is a fantastic resource.
  • Postfix Operators (Function Calls) - Crafting Interpreters: This chapter provides a great explanation of parsing postfix operators in a precedence-aware way, which is directly applicable to our field access operator.
  • The getelementptr (GEP) Instruction: As a look ahead to code generation, start familiarizing yourself with this essential LLVM instruction. It’s the key to calculating the memory address of a struct field.

Implement a Built-in print Function using FFI

Mục tiêu: Create a Foreign Function Interface (FFI) to call the external C printf function from a custom language. This involves declaring the external function in LLVM, generating a wrapper function to handle type differences, and registering the new built-in function in the compiler’s semantic analyzer.


Excellent work on implementing structs and field access! You have endowed SimpliLang with the power to create complex, aggregate data types, a massive step towards writing more realistic and organized programs. You’ve mastered how to manage the flow of data within your language.

Now, we will embark on a new, exciting journey: bridging the gap between the self-contained world of SimpliLang and the vast, powerful ecosystem of existing code in the outside world. Any new language needs an “escape hatch”—a way to leverage decades of battle-tested libraries for tasks like printing to the console, reading files, or allocating memory. This mechanism is called a Foreign Function Interface (FFI), and it is the key to bootstrapping a language’s standard library.

This task is your “Hello, World!” for FFI. We will implement a print(n: int) function in SimpliLang. But instead of writing the complex, OS-specific logic for console output ourselves, we will simply call the universal C standard library function: printf.

Part 1: The Strategy - A Compiler-Provided “Built-in” Function

Directly exposing the C printf function to SimpliLang users would be complicated. Its C signature is int printf(const char* format, ...);, which involves concepts like variadic arguments and C strings that SimpliLang doesn’t have yet.

A much cleaner approach is to provide a simplified, user-friendly wrapper. We will make our compiler automatically generate a SimpliLang-compatible print function with the signature fn print(value: int). The body of this function, which we will also generate, will contain a hardcoded call to the external C printf function with the format string "%d\n".

To achieve this, we will perform three key steps:

  1. Declare printf: Teach the CodeGen module to declare the external printf function in every LLVM module it creates. This tells LLVM, “This function exists somewhere else; the linker will find it.”
  2. Generate the print wrapper: Teach the CodeGen module to generate the full LLVM IR for our SimpliLang print function, which in turn calls printf.
  3. Register the print function: Teach the SemanticAnalyzer to add our new print function to the global symbol table so that user code can call it.

Part 2: Declaring an External Function in LLVM

The first step is to inform LLVM about printf. We don’t provide a body, just the function’s “signature” or “prototype.” We’ll do this during the initialization of your CodeGen struct.

In your src/codegen.rs, let’s modify the CodeGen::new constructor.

// In src/codegen.rs

// Add these to your `use` statements at the top
use inkwell::types::{IntType, PointerType};
use inkwell::values::FunctionValue; // To store the function for later use

// Add a new field to your CodeGen struct to hold a reference to the printf function
pub struct CodeGen<'ctx> {
    // ... existing fields: context, builder, module, etc.
    printf_fn: FunctionValue<'ctx>,
}

impl<'a, 'ctx> CodeGen<'ctx> {
    pub fn new(
        context: &'ctx Context,
        module_name: &str,
        optimize: bool,
    ) -> Self {
        // ... existing setup for context, builder, module ...

        // --- NEW: Declare the external `printf` function ---
        let printf_fn = Self::declare_printf(context, &module);

        CodeGen {
            // ... existing initializations ...
            printf_fn,
        }
    }

    /// Declares the external C `printf` function in the LLVM module.
    /// The signature is `int printf(char*, ...)`
    fn declare_printf(context: &'ctx Context, module: &Module<'ctx>) -> FunctionValue<'ctx> {
        // Get the LLVM types for the function signature.
        let i32_type = context.i32_type(); // `int` in C
        let i8_ptr_type = context.i8_type().ptr_type(AddressSpace::Generic); // `char*` in C

        // Create the function type.
        // The `true` at the end signifies that this is a "variadic" function,
        // meaning it can accept a variable number of arguments (like printf).
        let printf_type = i32_type.fn_type(&[i8_ptr_type.into()], true);

        // Add the function declaration to the module. This tells LLVM the function
        // exists but its body is defined elsewhere (to be resolved by the linker).
        module.add_function("printf", printf_type, Some(Linkage::External))
    }

    // ... rest of your CodeGen implementation ...
}

Dissecting the declare_printf Logic

  • Variadic Functions: printf is a classic variadic function, meaning it can take a variable number of arguments after the first one. When creating its type with fn_type, the final true argument is crucial to tell LLVM this.
  • add_function: This inkwell method adds a function to the module.
  • Linkage::External: This is the key. It tells LLVM that this function is defined in another module (in this case, the C standard library) and that the linker is responsible for finding it and wiring up the calls correctly.

Part 3: Generating the print Wrapper and Registering It

Now that printf is known to LLVM, we can create our SimpliLang print function that calls it. We will also create this as a “built-in” part of our compiler. A good place to do this is right after declaring printf.

// In src/codegen.rs, inside `impl CodeGen`

// ... inside the `new` function, after declaring printf ...
pub fn new(...) -> Self {
    // ...
    let printf_fn = Self::declare_printf(context, &module);

    // --- NEW: Generate our built-in `print` function ---
    Self::create_print_wrapper(context, &module, &builder, printf_fn);

    CodeGen {
        // ...
        printf_fn,
    }
}

/// Creates our user-facing `print(n: int)` function.
/// This function is a wrapper around the C `printf`.
fn create_print_wrapper(
    context: &'ctx Context,
    module: &Module<'ctx>,
    builder: &Builder<'ctx>,
    printf_fn: FunctionValue<'ctx>,
) {
    // 1. Define the function signature: `fn print(int) -> void`
    let i64_type = context.i64_type(); // Our SimpliLang `int` is i64
    let print_fn_type = context.void_type().fn_type(&[i64_type.into()], false);
    let print_fn = module.add_function("print", print_fn_type, None);

    // 2. Create the entry basic block for the function body.
    let entry_block = context.append_basic_block(print_fn, "entry");
    builder.position_at_end(entry_block);

    // 3. Create the format string: "%d\n".
    // `build_global_string_ptr` creates a constant string in the global
    // section of the object file and gives us a pointer to it.
    let format_str = builder
        .build_global_string_ptr("%d\n", "format_str")
        .as_pointer_value();

    // 4. Get the integer argument passed to `print`.
    let value_to_print = print_fn.get_first_param().unwrap().into_int_value();

    // 5. Build the call to `printf`.
    // We pass the format string pointer and the integer value as arguments.
    builder.build_call(
        printf_fn,
        &[format_str.into(), value_to_print.into()],
        "printf_call",
    );

    // 6. Build the return instruction (since it's a void function, it's just `ret void`).
    builder.build_return(None);
}

// And a crucial last step: Modify your `SemanticAnalyzer::new` constructor
// to pre-populate the symbol table with our new function.

// In src/semantic.rs
pub struct SemanticAnalyzer {
    // ...
}

impl SemanticAnalyzer {
    pub fn new() -> Self {
        let mut analyzer = SemanticAnalyzer { /* ... */ };

        // --- NEW: Register the built-in `print` function ---
        // We add "print" to the global scope before analyzing any user code.
        // We define its type as a function that takes one `int` and returns `void`.
        // (Assuming you have a `DataType::Function` and `DataType::Void`).
        analyzer.symbol_table.insert(
            "print".to_string(),
            Symbol::new(DataType::Function {
                param_types: vec![DataType::Int],
                return_type: Box::new(DataType::Void),
            }),
        );
        analyzer
    }
    // ...
}

Now, when a SimpliLang user writes print(42);, the semantic analyzer will find “print” in its symbol table, validate that the argument is an int, and the code generator will correctly generate a call to your built-in print function.

Enhancements and the Road Ahead

Congratulations! You have successfully opened a portal between SimpliLang and the outside world. Your language can now produce observable output, a monumental step in its usability.

This hardcoded “built-in” approach is a fantastic start. The next logical evolution of this feature would be to allow users to declare their own external functions. This would involve:

  • Designing an extern fn syntax: Create a way for users to write declarations like extern fn puts(s: *char) -> int; in their own code.
  • Updating the Parser: Teach the parser to recognize this new syntax and create an ExternFnDef AST node.
  • Updating the Semantic Analyzer: When it sees an ExternFnDef, it should register the function in the symbol table.
  • Updating the Code Generator: When it sees an ExternFnDef, it should call the declare_printf-like logic to add the function prototype to the LLVM module.

You have built an incredibly powerful and extensible compiler. The foundation is solid, and you are now free to explore the vast and exciting world of language features.

Further Reading