DEV Community

Cover image for How I Built a Programming Language in C#: HydraScript
StepOne
StepOne

Posted on

How I Built a Programming Language in C#: HydraScript

Most backend developers spend more time moving JSON than designing languages. I wanted a project that forced me below the framework layer, so I built the HydraScript programming language in vanilla C# and then used it to move JSON through CGI in Docker—because apparently ordinary JSON plumbing was not complicated enough.

I am Stepan Minin, a C# developer with more than seven years of commercial experience and a background in systems programming and compiler construction. This article is the short, practical tour: where HydraScript came from, which static-analysis ideas it explores, how to install it, and what it takes to run a script as an HTTP endpoint.

Disclaimer

The HydraScript interpreter was built entirely in vanilla C#. No compiler tools such as lex, flex, yacc, bison, ANTLR, or LLVM were used, and none are planned.

Why I Built HydraScript in C

The project began as my bachelor’s thesis, “An Extended Subset of JavaScript.” I took a small part of the ECMA-262 standard and added my own ideas. After graduating from the IU-9 department at Bauman Moscow State Technical University, I continued the project under the code name “HydraScript.”

Fix one bug, and several more take its place!

Four years of evening development went by almost unnoticed. The project strengthened my architecture and .NET skills. I also built a new implementation of the Visitor pattern.

The open-source HydraScript repository also gained four goals:

  1. Partially implement JavaScript with objects and strong structural static typing, without keywords such as constructor, class, or interface.

  2. Publicly reverse-engineer modern static analysis: type inference, forward references, catching runtime errors at compile time, and more.

  3. Demystify compiler construction and make the field more approachable through HydraScript’s source code.

  4. Collect understandable solutions to standard compiler problems: lexers, parsers, CFG, SSA, DCE, and so on.

How to Install HydraScript

The project has outgrown its thesis roots. It is now an open-source project with CI/CD through GitHub Actions, semantic versioning, development standards, PR templates, a backlog, and automated releases.

Each release builds the interpreter as a dependency-free Native AOT binary for three platforms:

  1. Windows (x64)

  2. macOS (arm64 Apple Silicon)

  3. Linux (x64)

The latest release is available on GitHub. Alternatively, install HydraScript as a .NET tool:

dotnet tool update --global hydrascript
Enter fullscreen mode Exit fullscreen mode

NuGet: https://www.nuget.org/packages/hydrascript

HydraScript Language and Static-Analysis Features

I will not cover every language feature and construct in detail; that would make this article far too long. The current English documentation is available in the GitHub repository:

Instead, let’s focus on the highlights. Most of them are static-analysis achievements.

Detecting Variable Access Before Initialization

If you write a TypeScript program that accesses a variable before a value has been assigned to it, the failure happens at runtime. The compiled JavaScript throws an error during execution. HydraScript catches the same problem during static analysis:

let x = f()

function f() {
    console.log(x)
    return 5
}
Enter fullscreen mode Exit fullscreen mode

Every Variable Has a Default Value

C# variables must be assigned before they are read. The following code produces the error Local variable ‘x’ might not be initialized before accessing:

int x;
Console.WriteLine(x);
Enter fullscreen mode Exit fullscreen mode

In HydraScript, however, a variable immediately receives a default value. The interpreter must infer its type first. If the type cannot be inferred, static analysis reports Cannot define type:

let x: number
>>> x // 0

let xArr: number[]
>>> xArr // []

let s: string
>>> ~s // 0
Enter fullscreen mode Exit fullscreen mode

Separate Identifier Namespaces for Symbols

To a compiler, a symbol is an entity in a program that source code can refer to: a variable, type, function, class, and so on. Although symbols can have different kinds, most languages still impose shared naming restrictions. For example, the following C# code does not compile, even though the identifier x clearly means different things in different places:

x x = new();

x x(x x)
{
    Console.WriteLine(x);
    return x;
}

class x
{
    x x(x x)
    {
        Console.WriteLine(x);
        return x;
    }
}
Enter fullscreen mode Exit fullscreen mode

The IDE reports three errors:

The Problems tab in JetBrains Rider

The Problems tab in JetBrains Rider

HydraScript currently has three symbol kinds:

  1. VariableSymbol — a variable or object

  2. TypeSymbol — a type

  3. FunctionSymbol — a function or method

An identifier needs to be unique only within its symbol kind. A script may therefore contain a type, variable, and function with the same name and still execute successfully:

type x = number

let x: x

function x(x: x) {
    >>> x
    x = x + 1
}

x(x)
Enter fullscreen mode Exit fullscreen mode

Serving JSON from HydraScript with CGI and Docker

As HydraScript grew, I wanted it to become more than yet another student interpreter. I am a backend developer, so naturally I steered my project toward moving JSON around.

Could I mature the language enough to build a web server in it? Not with the time I currently have. Could I make it compatible with CGI scripting? That was much easier. The language only needed:

  • A string API

  • Output to stdout

  • Access to environment variables ($ENV)

  • Support for shebang comments, which begin with a hash (#)

What Is CGI Scripting?

In plain English, CGI scripting is a set of conventions that turns a website from a static book into a live page. When the server receives a request:

  • It puts the required data into env environment variables.

  • It launches a script with the interpreter named by the shebang line, such as #!/usr/bin/bash.

  • The script writes its response to stdout, much like Console.WriteLine in C#.

  • The server captures that stream, turns it into a web page, and sends it back to the browser.

That is the simplified version. For the full technical definition, see the official RFC 3875 specification.

For another explanation, see this Russian-language Habr article.

I added all of this in release 2.6.0. The next step was to find a CGI-capable web server that could be deployed in Docker with a couple of commands. The httpd image turned out to include Apache 2. All I had to do was add the interpreter binary, which I installed as a dotnet tool:

FROM mcr.microsoft.com/dotnet/sdk:10.0-alpine as sdk-build
RUN dotnet tool update -g hydrascript
ENV PATH="/root/.dotnet/tools:${PATH}"

FROM httpd:2.4-alpine
COPY --from=sdk-build /root/.dotnet/tools/ /usr/bin
RUN apk add dotnet10-runtime
Enter fullscreen mode Exit fullscreen mode

I then built the image with docker-compose, adding the Apache configuration and CGI scripts. For the demo, I wrote a program that parses a QueryString into an array of name-value objects. Given a=1&b=bla&c=false, for example, the server should return this JSON:

{
    "result": [
        {
            "name": "a",
            "value": "1"
        },
        {
            "name": "b",
            "value": "bla"
        },
        {
            "name": "c",
            "value": "false"
        }
    ]
}
Enter fullscreen mode Exit fullscreen mode

The script is fairly large, so the listing is presented as a separate section. The complete setup, including the configuration and Compose file, is available in this GitHub repository:

uri_parse.cgi

#!/usr/bin/hydrascript

type QueryStringParseResultItem = {
    name: string;
    value: string;
}

type QueryStringParseResult = {
    result: QueryStringParseResultItem[];
}

type QueryStringParser = {
    input: string;
}

function parse(parser: QueryStringParser): QueryStringParseResult {
    const qsLen = ~parser.input
    let i = 0
    let items: QueryStringParseResultItem[]
    let currentName: string, currentValue: string
    let isName = true

    while (i < qsLen) {
        const currentChar = parser.input[i]

        if (currentChar == "&" || i == qsLen - 1) {
            let addittion = i == qsLen - 1 && currentChar != "&" ? currentChar : ""
            items = items ++ [{name: currentName; value: currentValue + addittion;}]
            currentName = ""
            currentValue = ""
            isName = true
        } else if (currentChar == "=") {
            isName = false
        } else {
            if (isName) {
                currentName = currentName + currentChar
            } else {
                currentValue = currentValue + currentChar
            }
        }

        i = i + 1
    }

    return {
        result: items;
    }
}

let parser: QueryStringParser = {
    input: $QUERY_STRING;
}

>>> "Content-Type: application/json\n\n"
>>> parser.parse()
Enter fullscreen mode Exit fullscreen mode

Testing the server response with Bruno

Testing the server response with Bruno

Takeaways

HydraScript is not an argument for replacing production application code with a new language. It is an open-source laboratory for lexing, parsing, type inference, control-flow analysis, and language design—implemented in the same C# ecosystem many backend developers use every day.

If you want to inspect the implementation, try the CLI, or contribute to the compiler, start with the repository:

Other StepOne Projects

Follow StepOne on GitHub for open-source C#/.NET projects, compiler experiments, production-focused examples, and new releases.

Top comments (0)