Back to Home

Creating DSL in Python: Russian-language interpreter

Detailed breakdown of creating DSL LawScript in Python: architecture without a separate lexer, preprocessor with line metadata, expression compilation to RPN and AST serialization for faster startup. Code and principles for technical audience.

How I created a Russian-language DSL in Python for my thesis
Advertisement 728x90

# Creating a DSL in Python: How I Built a Russian-Language Interpreter for My Diploma Project

The LawScript interpreter isn't just an academic experiment—it's a practical DSL with an imperative subsystem, built in Python to tackle legal challenges. The author combined declarative contract descriptions with procedural logic for rule validation, creating a hybrid language approved by the department as a thesis project. At its core: a parser without a separate lexer, a preprocessor with line metadata, and compilation to an AST serialized as "bytecode".

Lexer-Free Architecture: When Reinventing the Wheel Is a Feature

The usual pipeline of "lexer → parser → compiler → interpreter" is deliberately broken here: lexical analysis is embedded directly in the parser. Each line of source code is wrapped in a Line class that stores the line number, file path, and original text. This preserves error context and simplifies debugging. Example structure:

class Info(NamedTuple):
    num: int
    file: str
    raw_line: str

class Line(str):
    def __new__(cls, value: str, num: int = 0, file: str = ""):
        obj = str.__new__(cls, value)
        obj.raw_data = value
        obj.num = num
        obj.file = file
        return obj

    def get_file_info(self) -> Info:
        return Info(num=self.num, file=self.file, raw_line=self.raw_data)

The preprocessor handles VKLYuChIT directives, recursively loading modules from the current directory or standard library. It supports three file types: .raw (source files), .law (serialized AST), .pyl (Python extensions). Imports are cached to avoid reprocessing.

Google AdInline article slot

Parsing via Composition: Grammar as a Set of Microservices

The parser is implemented as an abstract base class, with specialized parsers inheriting from it for each grammatical construct. This allows nesting parsers: upon encountering a familiar construct, the current parser delegates to its child. The result is a MetaObject tree, which is then validated and converted to executable objects.

Lexical analysis is handled by the separate_line_to_token method, which splits the line into tokens while ignoring comments and checking bracket balance. On errors, it provides a positional hint—a cursor points to the problematic symbol. For example, an extra comma in an expression:

if token_ == Tokens.right_bracket:
    sub_expr = expr[offset:]
    previous_tok = sub_expr[offset_ - 1]
    if previous_tok == Tokens.comma:
        err_expr = ''.join([str(i) for i in sub_expr][:offset_+1])
        sub_expr = [str(i) for i in sub_expr]
        res_expr = ''.join(str(i) for i in expr)
        target_comma = (
            f"{err_expr}\n"
            f"{' ' * (sum(len(t) for o, t in enumerate(sub_expr) if o < offset_ - 1))}^"
        )
        raise InvalidExpression(
            f"In vyrazhenii: '{res_expr}' worth lishnyaya zapyataya '{Tokens.comma}'\n\n"
            f"{target_comma}\n"
        )

Reverse Polish Notation and Complex Expressions

Expressions of any complexity, including default arguments, are compiled into an RPN stack. This correctly handles operator precedence and nested function calls. For example, an array sorting procedure:

Google AdInline article slot
OPREDELIT PROTsEDURU sortirovka_array(array_numbers) (
    SET length = length_array(array_numbers);
    SET minimum_index = 0;

    LOOP index FROM 0 TO length-1 (
        minimum_index = index;
        LOOP internal_index FROM index+1 TO length-1 (
            IF fetch_from_array(array_numbers, internal_index) MENShE fetch_from_array(array_numbers, minimum_index) THEN (
                minimum_index = internal_index;
            )
        )
        IF minimum_index NERAVNO index THEN (
            SET temporary_variable = fetch_from_array(array_numbers, index);
            change_in_array(array_numbers, index, fetch_from_array(array_numbers, minimum_index));
            change_in_array(array_numbers, minimum_index, temporary_variable);
        )
    )
    NAPEChATAT array_numbers;
)

This transforms into an AST like this:

[
  ["AssignField", "TARGET", "length", "EXPR", [...]],
  ["AssignField", "TARGET", "minimum_index", "EXPR", [Number(0)]],
  ["Loop",
    "FROM_EXPR", [Number(0)],
    "TO_EXPR",   [Service name: <length>, Number(1), Service name: <->],
    [
      ["AssignOverrideVariable", "TARGET_EXPR", [...], "OVERRIDE_EXPR", [...]],
      ["Loop",
        "FROM_EXPR", [...],
        "TO_EXPR",   [...],
        [
          ["When", "EXPR", [...],
            [["AssignOverrideVariable", ...]]
          ]
        ]
      ],
      ["When", "EXPR", [...]],
      [["AssignField", ...], [...], [...]]
    ]
  ],
  ["Print", "EXPR", [Service name: <array_numbers>]]
]

Compiler and Execution: From Tree to Actions

The compiler traverses the AST, transforming each node into an executable object. For instance, AssignField becomes a variable assignment, Loop a loop with runtime-computed bounds. The interpreter executes these objects sequentially, managing a call stack and local variables. For faster reruns, the AST is serialized to a .law file—"bytecode"—loaded without re-parsing.

What’s Important

  • Dual-Nature DSL: declarative contracts + imperative logic = unique hybrid for legal tasks.
  • Metadata per Line: Line class preserves context for precise error diagnostics.
  • Parser Composition: each grammatical construct is its own class, easing language extensions.
  • RPN for Expressions: handles complex arithmetic and default arguments without external dependencies.
  • AST Serialization: .law file acts as a cache, speeding up launches after initial compilation.

— Editorial Team

Google AdInline article slot
Advertisement 728x90

Read Next