Lyric Language Specification

Version 1.1.0, March 2026

Table of Contents


1. Overview

Lyric is a modern, beginner-friendly programming language designed for simplicity, readability, and teaching. It blends Python's clean structure with a clear, expressive syntax that emphasizes understanding over complexity. Lyric is intended for use in introductory computer science courses, helping students learn core programming concepts through an approachable and consistent language design.

The language is implemented in Python and currently supports core programming constructs including functions, conditionals, loops, classes, inheritance with access control, file I/O, shell integration, and built-in data structures with comprehensive error handling and performance optimizations.

1.1 Execution Model

Lyric uses a dual execution architecture with two backends that produce identical results for all valid programs:

  1. Bytecode Compiler (default) β€” Lyric source is parsed into an AST, then transpiled to Python ast module nodes and compiled to CPython bytecode via compile(). The compiled bytecode is executed with exec(), achieving near-native Python performance. This is a transpiler β€” Lyric AST nodes are mapped to equivalent Python AST constructs, with a runtime support library (compiled_runtime) handling Lyric-specific semantics like typed assignments, string auto-coercion, and file I/O operators.

  2. Tree-Walking Interpreter (fallback) β€” The original interpreter walks the Lyric AST directly, evaluating each node recursively with lexical scope managed through linked environment frames. This mode is available via the --interpret flag and is used automatically by the REPL (lyric -i).

Default (compiled):
  Source (.ly) β†’ Lexer β†’ Parser β†’ Lyric AST β†’ Compiler β†’ Python AST β†’ compile() β†’ exec()

With --interpret:
  Source (.ly) β†’ Lexer β†’ Parser β†’ Lyric AST β†’ Tree-Walking Interpreter

The bytecode compiler is the default because it provides significantly better performance for compute-heavy workloads (20-50x speedup for loops and function calls). The tree-walking interpreter remains available as a reference implementation and fallback.

The interactive REPL (lyric -i) always uses the tree-walking interpreter, as it is better suited for incremental evaluation of individual expressions and statements with persistent state across inputs.


2. Syntax Design Philosophy

Readable, structured, and designed for clarity and simplicity

  1. Clarity before cleverness β€” syntax must express intent plainly.
  2. Simplicity over completeness β€” features exist only when they add clarity.
  3. Visual harmony β€” code structure and syntax designed to be logical and easy to read.
  4. Consistency β€” similar constructs end with parallel patterns (end, done, fade, +++).
  5. Braces only where needed β€” functions use braces; everything else uses natural terminators.

3. Syntax Summary

Construct Summary
Conditional Opens with if, elif (or case), or else; closes with end. Python-style conditionals.
For Loop for type var in ... iterates over a range or collection with inline variable declaration, closes with done. Supports break and continue.
Given Loop given condition: repeats while the condition is True (while loop), closes with done. Supports break and continue.
Function Defined with def funcname() or with return type int add(). Enclosed in { ... }.
Class Declared with class, closed by +++. Supports constructors and inheritance with based on.
Inheritance Single inheritance using class Child based on Parent: syntax. Automatic constructor chaining.
Access Modifiers Methods support public, private, protected keywords. Attributes remain public.
Try / Except Structured with try, catch (with optional type), finally, closed by fade.
Import System Uses import for Lyric modules and importpy for Python bridges. importpy enforces a two-tier blacklist: module-level (26 blocked modules) and attribute-level (9 blocked sys attributes).
Entry Point Always begins with def main() or def main(int argc, arr argv) for command-line arguments.
Lists Declared with [ ... ]. Built-in sequence type. Supports slicing [start:end:step].
Dictionaries Declared with { ... }. Built-in key/value data type.
Type Declarations Supports int, str, flt, rex, bin/god, arr, tup, map, obj, dsk, pyobject, and var.
File I/O dsk type with 11 methods. File operators: ->> (append), -> (write), <- (read).
Shell Integration exec() for commands, exit() for termination. Chain operators: `
Regular Expressions Created with regex("pattern") function. Supports full regex operations.
Shebang Support Scripts may start with #!/usr/bin/lyric or #!/usr/bin/env lyric for direct execution.

4. Basic Structure

Every Lyric program starts execution in a main function.
There is no __name__ or hidden runtime boilerplate.

def main() {
    print("Hello, world!")
}

5. Core Constructs

5.1 Conditionals

if x > 0:
    print("positive")
elif x < 0:
    print("negative")
else:
    print("zero")
end

# Alternative with case (equivalent to elif)
if score > 90:
    print("A")
case score > 80:
    print("B")
case score > 70:
    print("C")
else:
    print("F")
end

5.2 For Loops

# Iterate over a range of numbers
for int i in range(5):
    print(i)
done

# Iterate over an array
for str color in ["red", "green", "blue"]:
    print(color)
done

5.3 Given Loops (While)

# While-style loop
given n > 0:
    print(n)
    n -= 1
done

5.4 Break and Continue

for int i in range(10):
    if i == 5:
        break  # Exit loop when i equals 5
    end
    if i == 3:
        continue  # Skip iteration when i equals 3
    end
    print(i)
done

Note: break and continue are legal only inside for and given loops. They are implemented as control flow signals (not exceptions) and cannot be caught by try/catch blocks.

5.5 Functions

Functions can be declared in two ways:

  1. Typed Functions β€” with return type before the function name (without def)
  2. Untyped Functions β€” using only the def keyword

Important: Do not mix these forms. Choose one style for each function.

Note: Lyric does not support keyword arguments. All function arguments are positional only.

5.5.1 Typed Functions with Return Type

# Explicit return type (no def keyword)
str greet(str name) {
    return "Hello, " + name
}

int add(int a, int b) {
    return a + b
}

flt calculate(int x, flt y) {
    return x * y
}

5.5.2 Untyped Functions

# Simple function with no return type (uses def)
def greet(name) {
    print("Hello,", name)
}

def calculate(x) {
    return x * 2
}

5.5.3 Type Inference and Return Validation

5.5.4 Typed Functions with Return Type

# Explicit return type
str greet(str name) {
    return "Hello, " + name
}

int add(int a, int b) {
    return a + b
}

# Return type inference (automatically inferred from return statements)
def multiply(int a, int b) {
    return a * b  # Inferred as int
}

5.5.5 Untyped Functions

# Simple function with no return type
def greet(name) {
    print("Hello,", name)
}

def calculate(x) {
    return x * 2
}

5.5.6 Type Inference and Return Validation

# Type inference example
def abs(int n) {
    if n < 0:
        return -n  # int
    else:
        return n   # int
    end
}  # Inferred return type: int

# Type validation error example
def mystery(int n) {
    if n > 0:
        return "positive"  # str
    else:
        return 0  # int
    end
}  # CompileError: Inconsistent return types

6. Type System

Lyric supports the following type declarations:

Type Description Example
int Integer numbers int count = 42
flt Floating-point numbers flt pi = 3.14159
str Text strings with methods str name = "Alice"
god / bin Boolean values (aliases) god flag = true or bin flag = false
rex Regular expression patterns rex pattern = regex("hello")
arr Arrays/lists with methods arr items = [1, 2, 3]
tup Immutable tuples tup coords = (10, 20)
map Dictionaries with methods map data = {"key": "value"}
obj Class instance references obj person = Person("Alice")
dsk File handle for I/O operations dsk file = disk("data.txt")
pyobject Python object references pyobject obj = some_python_object
var Dynamic type (can change) var x = 10

6.1 Type Compatibility Rules

6.2 Scope and Redeclaration


6.3 Variable Declaration and Assignment

int count = 10
count = "ten"  # TypeError
var value = 10
value = "ten"  # Allowed

6.3.1 Declaration Syntax

Dynamic Typing with var:

var x = 10           # Dynamic variable, can change type
var name = "Alice"   # Another var that is of str type
x = "hello"          # Type change allowed for var x variable

Static Type Declarations:

int count = 5        # Integer variable
str message = "Hi"   # String variable
flt pi = 3.14        # Float variable
var dynamic = true   # Dynamic variable (can change type)

Class Instance Type (obj):

obj person = Person("Alice")   # Must hold a class instance
obj account = BankAccount(0)   # Type error if assigned a non-instance value

obj enforces that the variable holds a Lyric class instance. Assigning a plain value (e.g. an integer or string) to an obj variable raises a runtime type error. var remains valid for class instances when dynamic typing is needed.

Boolean Types:

god flag = true      # Boolean variable (honoring Kurt GΓΆdel)
bin status = false   # Alternative boolean syntax (binary 0/1)

Note: god and bin are fully interchangeable aliases for the boolean type. Both work identically in all contexts.

Multi-Variable Declarations:

# Simple multi-declarations (same base type)
var x, y, z
int a, b, c
str name, message
# Mixed-type multi-declarations
var x, int y, str z
int count, var temp, flt pi

6.3.2 Declaration and Initialization Rules

  1. Declaration Before Use: All variables must be explicitly declared before assignment or reference
  2. Initialization Tracking: Variables are marked as declared but uninitialized until first assignment
  3. Type Enforcement: Static types cannot change at runtime
  4. Dynamic Flexibility: var variables can change type during reassignment

6.3.3 Error Handling

Undeclared Variable Assignment:

def main() {
    x = 10  # Error: Variable 'x' used before declaration
}

Referencing Uninitialized Variable:

def main() {
    var x    # Declared but uninitialized
    print(x) # Error: Variable 'x' referenced before assignment
    x = 10   # Now initialized
    print(x) # Works: prints 10
}

Type Mismatch:

def main() {
    int x = 10
    x = "hello"  # Error: Type mismatch: cannot assign str to variable 'x' declared as int. Expected int, but got str. Use 'var x = ...' if you need dynamic typing.
}

Invalid Type in Multi-Declaration:

def main() {
    invalid_type x, int y  # Error: Unknown type 'invalid_type' at line 1, column 1. Valid types are: int, str, flt, var
}

6.3.4 Function Type Declarations

# Typed function with parameters and return type
int add_numbers(int a, int b) {
    return a + b
}

# Mixed typed and untyped parameters
str format_greeting(str name, int age) {
    return "Hello " + name + ", age " + str(age)
}

# Dynamic function (no type declarations)
def greet(name) {
    print("Hello", name)
}

def main() {
    var result = add_numbers(5, 10)        # Dynamic variable
    int sum = add_numbers(20, 30)          # Static variable
    str greeting = format_greeting("Alice", 25)
    greet("World")
}

6.4 Compound Assignment Operators

Compound assignment operators combine an arithmetic operation with assignment into a single step. x += 5 is equivalent to x = x + 5.

Operator Equivalent Description
+= x = x + expr Add and assign
-= x = x - expr Subtract and assign
*= x = x * expr Multiply and assign
/= x = x / expr Divide and assign
%= x = x % expr Modulo and assign

6.4.1 Type Compatibility

Operator int flt str
+= Yes Yes Yes (concatenation)
-= Yes Yes TypeError
*= Yes Yes Yes (repetition: str *= int)
/= Yes (returns flt) Yes TypeError
%= Yes TypeError TypeError

Notes:

6.4.2 Assignment Targets

Compound assignment works on all standard assignment targets:

# Simple variables
int x = 10
x += 5       # x is now 15

# Indexed access (arrays and maps)
arr scores = [10, 20, 30]
scores[0] += 5    # scores[0] is now 15

Member access (object fields):

class Counter:
    def Counter() {
        self.count = 0
    }
    def increment() {
        self.count += 1
    }
+++

6.4.3 Common Patterns

# Accumulator in a loop
int total = 0
var i
for i in [1, 2, 3, 4, 5]
    total += i
done
# total is 15

# String building
str result = ""
var word
for word in ["hello", " ", "world"]
    result += word
done
# result is "hello world"

7. Classes and Inheritance

7.1 Classes

# Constructor example (method matching class name)
class Player:
    var name
    var score
    
    def Player(str player_name) {
        self.name = player_name
        self.score = 0
        print("Player created:", self.name)
    }
    
    def greet() {
        print("Hello,", self.name)
    }
+++

var p = Player("Alice")  # Constructor called automatically
p.greet()

# Traditional init method (still supported)
class Game:
    score = 0
    
    def init() {
        self.score = 0
        print("Game initialized")
    }
    
    def update_score(points) {
        self.score += points
    }
+++

7.2 Inheritance

Lyric supports single inheritance using the based on syntax. Subclasses inherit all methods and attributes from their base class.

7.2.1 Syntax

class Parent:
    var x
    
    def Parent() {
        self.x = 10
    }
+++

class Child based on Parent:
    var y
    
    def Child() {
        self.y = 20
    }
+++

7.2.2 Constructor Chaining

Constructors are automatically chained from base to child when inheritance is used. If a class has no constructor, it is skipped.

class A:
    def A() { print("A") }
+++

class B based on A:
    def B() { print("B") }
+++

class C based on B:
    def C() { print("C") }
+++

var obj = C()  # Output: A, B, C

7.2.3 Method Overriding

Child classes can override parent methods by using the same name. The child method always takes precedence.

class Animal:
    def speak() { print("Some sound") }
+++

class Dog based on Animal:
    def speak() { print("Woof!") }
+++

var pet = Dog()
pet.speak()  # Output: Woof!

7.3 Access Modifiers

Methods can use public, private, or protected modifiers. Attributes remain public.

class Person:
    var name
    
    public def speak() {
        print("Hello from", self.name)
    }
    
    private def think() {
        print("(thinking)")
    }
    
    protected def internal_method() {
        print("Protected operation")
    }
+++

8. Data Structures

def main() {
    # Lists
    numbers = [1, 2, 3, 4, 5]
    print("First number:", numbers[0])
    
    # Slicing
    var text = "Hello, World!"
    print(text[0:5])    # "Hello"
    print(text[7:])     # "World!"
    print(text[:5])     # "Hello"
    print(text[::2])    # "Hlo ol!"
    
    # Dictionaries
    person = {"name": "Alice", "age": 30}
    print("Name:", person["name"])
}

8.1 str Type β€” String Methods

The str type represents text strings in Lyric. Strings are immutable β€” all methods return a new string rather than modifying the original. You must reassign the result if you want to keep it (e.g., s = s.upper()).

Declaration:

str name = "Alice"
str empty = ""
str greeting = "Hello, " + name

Case Methods:

Search Methods:

Modification Methods:

Formatting Methods:

Test Methods:

Example:

str text = "  Hello, World!  "

str trimmed = text.strip()       # "Hello, World!"
print(trimmed.upper())           # "HELLO, WORLD!"
print(trimmed.lower())           # "hello, world!"
print(trimmed.replace("World", "Lyric"))  # "Hello, Lyric!"

str csv = "a,b,c"
arr parts = csv.split(",")      # ["a", "b", "c"]
print(",".join(parts))           # "a,b,c"

str name = "alice"
print(name.startswith("al"))    # True
print(name.find("ic"))          # 2
print(name.count("a"))          # 1

str padded = "42".zfill(5)      # "00042"
str centered = "hi".center(10, "*")  # "****hi****"

8.2 arr Type - Array/List Methods

The arr type represents arrays/lists in Lyric with a comprehensive method API. All list operations are methods called on the array object.

Declaration:

arr numbers = [1, 2, 3, 4, 5]
arr names = ["Alice", "Bob", "Charlie"]
arr empty = []

Mutation Methods:

Query Methods:

Example:

arr items = [3, 1, 4, 1, 5, 9]

items.append(2)          # [3, 1, 4, 1, 5, 9, 2]
items.sort()             # [1, 1, 2, 3, 4, 5, 9]
print(items.len())       # 7
print(items.max())       # 9
print(items.count(1))    # 2

items.remove(1)          # Remove first 1
items.reverse()          # Reverse in place

8.3 map Type - Dictionary Methods

The map type represents dictionaries in Lyric with a comprehensive method API. All dictionary operations are methods called on the map object.

Declaration:

map person = {"name": "Alice", "age": 30}
map scores = {"math": 95, "english": 87}
map empty = {}

Mutation Methods:

Query Methods:

Example:

map data = {"a": 1, "b": 2, "c": 3}

print(data.len())           # 3
print(data.get("a"))        # 1
print(data.get("z", 0))     # 0 (default)

arr all_keys = data.keys()     # ["a", "b", "c"]
arr all_values = data.values() # [1, 2, 3]

data["d"] = 4               # Add new key-value
data.pop("b")               # Remove key "b"

# Membership check
if "a" in data
    print("Key 'a' exists")
end

8.4 tup Type β€” Immutable Tuples

The tup type represents fixed-length immutable sequences. Once created, a tuple's contents cannot be changed. This makes tup useful whenever you need to guarantee a sequence will not be modified, or when passing sequences to Python APIs that require native Python tuples.

Declaration:

tup coords = (10, 20)
tup mixed  = (1, "hello", 3.14)
tup empty  = ()
tup single = (42,)   # Trailing comma required for single-element tuples

Built-in conversion:

arr items = [1, 2, 3]
tup t = tup(items)   # Convert an arr to a tup

Indexing and iteration:

tup t = (10, 11, 12)
print(t[0])          # 10

var item
for item in t:
    print(item)
done

if 11 in t:
    print("found")
end

Read-only methods:

Mutation methods (append, remove, sort, etc.) do not exist on tup. Attempting to modify a tuple raises a TypeError.

Python interop: tup values are passed to Python as native tuple objects, which is required by Python APIs such as HTTPServer(("0.0.0.0", 8080), handler).

Example:

tup point = (3, 7)
print(point.len())        # 2
print(point.min())        # 3
print(point.max())        # 7

tup words = ("apple", "banana", "apple")
print(words.count("apple"))   # 2
print(words.index("banana"))  # 1

9. Modules and Imports

9.1 Importing Lyric Modules

Lyric supports importing functions and classes from other Lyric files using the import statement.

Important: Functions, classes, and module-level variables can be imported from Lyric modules.

9.1.1 Whole-Module Import (Namespace Access)

When you write import utils, Lyric creates a module namespace object bound to the name utils. All names from the module are accessed via dot notation: utils.func(), utils.VAR. Nothing is injected directly into the calling scope.

# Whole-module import β€” access everything through the namespace
import utils

def main() {
    int result = utils.calculate(10)
    obj person = utils.Person("Alice")
    print(utils.PI)
    print(result)
}

9.1.2 Selective Import Syntax

Import specific names using a semicolon separator. The listed names are bound directly into the calling scope β€” no module. prefix needed.

# Selective import β€” bind specific names directly into scope
import utils; calculate, format_name, Person

def main() {
    int result = calculate(10)       # Direct access
    str name = format_name("alice")  # Direct access
    obj p = Person("Bob")            # Direct access
}

Module Search Path:

Restrictions:

9.2 Python Library Import

importpy bridges Lyric programs into Python's standard library and any installed third-party packages. Access is mediated through a PyModuleProxy object that intercepts attribute lookups, enforces the blacklist, and forwards safe accesses directly to the underlying Python module.

9.2.1 Whole-Module Import

The original syntax imports a module under its name. All attributes are accessed via dot notation.

importpy random

def main() {
    print(random.randint(1, 10))
}

9.2.2 Selective Import Syntax

A semicolon after the module name, followed by a comma-separated list of names, binds those names directly into the calling scope. This mirrors Python's from module import Name1, Name2. Both functions and classes are supported. Dotted module names (e.g., http.server) are also supported.

# Bind sqrt and pi directly into scope
importpy math; sqrt, pi

def main() {
    print(sqrt(16.0))   # 4.0
    print(pi)           # 3.141592653589793
}
# Selective import from a dotted module
importpy http.server; HTTPServer, SimpleHTTPRequestHandler

def main() {
    tup addr = ("0.0.0.0", 8080)
    var server = HTTPServer(addr, SimpleHTTPRequestHandler)
    server.serve_forever()
}
# Mixing whole-module and selective imports
importpy os
importpy functools; partial

def main() {
    str cwd = os.getcwd()
    print(cwd)
}

All blacklist and whitelist rules apply equally to both import forms. A blacklisted module cannot be imported selectively any more than it can be imported whole.

9.2.3 Whitelisted Modules

The following modules have been reviewed and vetted to ensure they align with Lyric's runtime model. They may be imported directly with importpy without any additional flags.

Module Description
collections Specialized container types (see vet results below)
datetime Date and time utilities
http.server Simple HTTP server
json JSON encoding and decoding
math Mathematical functions and constants
os Operating system interface
random Random number generation
requests HTTP requests (third-party)
sys System-specific parameters (partially restricted β€” see Tier 2 blacklist)
time Time access and conversions

Modules not on the whitelist are blocked by default. Modules that are not explicitly blacklisted may be imported by running Lyric with the --unsafe flag. This flag enables broader Python interoperability and is intended for advanced users who understand the implications of stepping outside Lyric's controlled environment.

In a future version of Lyric, the use of importpy may require explicitly enabling a --interop flag. This would make Python interoperability an intentional opt-in feature, clearly separated from Lyric's core standard environment.

9.2.4 Collections Module Vet Results

The collections module has been vetted with 50/50 tests passing. The following types and operations are supported:

Type Supported Operations
Counter Creation, element access, missing key (returns 0), most_common, update, clear, keys, values, total
defaultdict Creation, explicit assignment, compound assignment
deque Creation, append, appendleft, pop, popleft, rotate, extend, extendleft, maxlen, len, clear, count, reverse
OrderedDict Creation, insertion order, move_to_end
namedtuple Creation, field access, index access
ChainMap Creation, lookup (override + fallthrough), keys

Known limitation: defaultdict auto-creation on missing keys does not work. Python-to-Lyric callback is not supported, so accessing a missing key will not trigger the default factory. Use explicit assignment instead.

9.2.5 Security Blacklist

Because Lyric runs on top of the CPython interpreter, an unrestricted importpy would allow Lyric programs to reach directly into CPython's internals β€” inspecting live stack frames, installing bytecode trace hooks, serializing and deserializing arbitrary Python objects, calling the raw compiler, or manipulating the garbage collector. This would undermine Lyric's runtime isolation, break the language's own error model, and open avenues for privilege escalation in shared or sandboxed environments.

The blacklist is enforced in two tiers:

Tier 1 β€” Module-level blacklist. These modules are rejected entirely at import time. Attempting importpy <name> raises a RuntimeErrorLyric immediately, before any module code is loaded.

Module Reason
pdb Interactive CPython debugger; installs frame-stepping hooks
trace Line-by-line CPython bytecode tracer
traceback Formats and walks live CPython stack frames
faulthandler Registers low-level signal/fault handlers tied to CPython internals
inspect Introspects live frames, bytecode, and source; principal frame-inspection API
dis CPython bytecode disassembler; requires direct access to CodeType objects
marshal Serialises/deserialises CPython bytecode and CodeType objects
pickle Serialises arbitrary Python object graphs including code objects; arbitrary code execution vector
pickletools Annotated pickle disassembler; same risk surface as pickle
copyreg Registers custom pickle reduction functions; extends pickle's attack surface
code Creates interactive CPython interpreter sessions
codeop Compiles partial Python source into CodeType objects
compile Direct access to the CPython compile built-in
eval Direct access to the CPython eval built-in
exec Direct access to the CPython exec built-in
types Exposes CodeType, FrameType, FunctionType, and other CPython-internal type constructors
importlib._bootstrap Private CPython import machinery bootstrap
importlib._bootstrap_external Private CPython external import path handler
zipimport Low-level CPython zip-archive importer
runpy Executes Python modules and scripts as __main__; arbitrary code execution
modulefinder Traces CPython's import graph by inspecting bytecode
gc Direct access to CPython's reference-counting garbage collector
weakref Exposes CPython object identity and finalization hooks
ctypes Calls arbitrary native C code; complete process memory access
cffi Alternative native C FFI; same risk as ctypes
_ctypes CPython's internal C extension backing ctypes
mmap Raw memory-mapped file access; process memory exposure

Tier 2 β€” Attribute-level blacklist. Some modules (currently sys) are partially useful to Lyric programs (e.g., sys.argv, sys.platform) but carry individual attributes that reach into CPython internals. These attributes are blocked at access time; the module itself may still be imported.

Blocked sys attributes:

Attribute Reason
settrace Installs a CPython bytecode trace hook on the current thread; used by debuggers and coverage tools to intercept every opcode
setprofile Installs a CPython call/return profile hook; exposes the entire call stack
gettrace Reads back the currently installed trace hook; blocked for symmetry with settrace
getprofile Reads back the currently installed profile hook; blocked for symmetry with setprofile
_getframe Returns a live FrameType object for an arbitrary depth in the call stack; most direct route into Lyric's own interpreter state
_current_frames Returns all live frames across all threads; same risk as _getframe at process scope
getrefcount Exposes CPython's reference count for a Python object, leaking object identity and memory layout
addaudithook Installs a permanent, process-wide, non-removable audit hook that can monitor every Python operation for the lifetime of the process
exc_info Returns (type, value, traceback) where the traceback object holds direct references to live CPython frame objects

9.2.6 Error Messages

When a blacklisted module or attribute is accessed, Lyric raises a RuntimeErrorLyric with a message of the form:

ImportError: Python module 'pickle' is blacklisted in Lyric.
This module relies on CPython bytecode, frames, object identity, or interpreter internals and does not work with Lyric.
AttributeError: 'sys._getframe' is blacklisted in Lyric.
This module relies on CPython bytecode, frames, object identity, or interpreter internals and does not work with Lyric.

The message is intentionally informative so that users understand the architectural reason for the restriction rather than seeing an opaque import failure.

9.2.7 Listing Whitelisted and Blacklisted Modules

You can view the current whitelist and blacklist from the command line:

lyric --whitelist

Prints the list of all whitelisted modules that may be imported directly with importpy.

lyric --blacklist

Prints the list of all blacklisted modules that are blocked under all circumstances.


10. File I/O

10.1 The dsk Type

The dsk type provides comprehensive file operations. Files are created using the built-in disk() function.

10.1.1 Declaration

dsk myfile = disk("data.txt")

10.1.2 File Methods

Method Description
open(mode='r') Open file with mode ('r', 'w', 'a', 'r+', etc.)
close() Close file handle
read() Read entire file as string
readlines() Read all lines as arr
write(content) Write content (overwrites file)
append(content) Append content to file
exists() Check if file exists (returns boolean)
size() Get file size in bytes
delete() Delete the file
copy(toPath) Copy file to new path
move(toPath) Move file to new path

10.1.3 Example

def main() {
    dsk logfile = disk("app.log")
    logfile.write("Application started\n")
    logfile.append("User logged in\n")
    logfile.close()
    
    if logfile.exists()
        print("Log size:", logfile.size(), "bytes")
    end
}

10.2 File Operators

Lyric provides intuitive operators for file I/O:

These operators work with str, arr, int, flt, god, and map types.

The print statement can also be used with file operators to write with an automatic newline β€” similar to how print adds a newline on stdout:

print "text" ->> myfile    # Appends "text\n" to file
print "text" -> myfile     # Overwrites file with "text\n"

This is useful for building multi-line files where each line needs a newline terminator.

10.2.1 Examples

def main() {
    dsk myfile = disk("data.txt")

    # Append string to file (no newline)
    str text = "Hello, World!"
    text ->> myfile

    # Append with newline using print
    print "Hello, World!" ->> myfile

    # Overwrite file
    str new_data = "New content"
    new_data -> myfile

    # Read from file
    str content
    content <- myfile
    print(content)

    # Array operations
    arr lines = ["line1", "line2", "line3"]
    lines ->> myfile  # Each element on new line

    arr file_lines
    file_lines <- myfile  # Reads lines into array

    # Build a multi-line file with print
    dsk journal = disk("post.md")
    print "---" ->> journal
    print "title: My Post" ->> journal
    print "---" ->> journal
    print "" ->> journal
    print "Content here." ->> journal
    journal.close()
}

11. Command-Line and Shell

11.1 Command-Line Interface

11.1.1 Execution Modes

By default, Lyric uses the bytecode compiler to execute programs. The following flags control execution behavior:

lyric script.ly                  # Run with bytecode compiler (default)
lyric run script.ly              # Same as above
lyric script.ly --interpret      # Use tree-walking interpreter instead
lyric script.ly --dump-ast       # Print the generated Python AST and exit
lyric -i                         # Start REPL (uses interpreter mode)
lyric -i "print(2 + 2)"         # Execute immediate code (uses interpreter mode)
Flag Description
(none) Bytecode compiled execution (default)
--interpret Use the tree-walking interpreter instead of the bytecode compiler
--dump-ast Print the generated Python AST without executing (useful for debugging the compiler)
--unsafe Allow importpy of non-whitelisted modules (blacklisted modules are still blocked)

The LYRIC_INTERPRET environment variable can also be set to force interpreter mode without passing the flag.

The REPL (lyric -i) and immediate execution (lyric -i "code") always use the tree-walking interpreter regardless of flags, as interpreter mode is better suited for incremental, stateful evaluation.

11.1.2 Script Arguments

The main() function can accept arguments passed from the command line.

def main(int argc, arr argv) {
    print("Argument count:", argc)
    for str arg in argv
        print("Arg:", arg)
    done
}

Run: lyric script.ly arg1 arg2 arg3

11.1.2 Option Parsing

The getopts() function retrieves command-line options. It always takes exactly two arguments: getopts(short, long) where the first is the short option name and the second is the long option name. Use None if you only need one form.

def main() {
    god verbose = getopts("v", "verbose")   # -v or --verbose
    god debug = getopts("d", None)           # -d only
    var file = getopts(None, "file")         # --file=path only
    god help = getopts("h", "help")          # -h or --help

    if help
        print("Usage: script.ly [options]")
        exit()
    end

    if verbose
        print("Verbose mode enabled")
    end

    if file != false
        print("Processing:", file)
    end
}

Run: lyric script.ly -d --verbose --file=data.txt

Rules:

11.2 Shell Integration

11.2.1 Program Termination

The exit() function terminates the program with an exit code.

def main() {
    if error_condition
        exit(1)  # Exit with error code
    end
    exit(0)  # Exit successfully (default)
}

11.2.2 Command Execution

The exec() function executes a shell command via the system shell and returns the command's exit code as an int. The command runs synchronously; exec() does not return until the process exits.

def main() {
    int rc = exec("ls -la")
    print("Command returned:", rc)
}

11.2.3 stderr Merge Contract

In all exec capturing paths, stderr is merged into stdout. A command's output is defined as the union of its standard output and standard error streams. This is a language-level guarantee, not a shell-level convention. There is no mechanism to capture stdout and stderr separately.

This contract applies uniformly across all four exec paths:

Path Syntax example Behaviour
Return-code only exec("cmd") stdout + stderr printed live; exit code returned
Output capture exec("cmd") -> var stdout + stderr captured into var
File append exec("cmd") ->> file stdout + stderr appended to dsk file
Pipeline stage exec("cmd1") | exec("cmd2") stdout + stderr of each stage are forwarded to the next

The rationale is that command-line tools frequently write diagnostic information, progress messages, or error details to stderr. Silently discarding stderr would cause programs to lose critical output that a user would expect to see or capture. Merging the streams gives Lyric programs a simple, predictable model: whatever a command prints, you get.

11.2.4 I/O Redirection

Commands can redirect output to variables or files:

def main() {
    # Capture stdout + stderr into a variable
    str output
    exec("echo Hello") -> output
    print("Got:", output)

    # Capture stderr-only commands (stderr is merged, so this works naturally)
    str err_output
    exec("ls /nonexistent/path") -> err_output
    print("Error output:", err_output)

    # Append stdout + stderr to a file
    dsk logfile = disk("output.txt")
    exec("date") ->> logfile
}

11.2.5 Command Chaining

Chain commands with pipe and control flow operators:

def main() {
    # Pipe commands
    str result
    exec("echo hello") | exec("cat") -> result
    
    # Conditional execution
    exec("make clean") && exec("make all") && exec("make test")
    
    # Fallback execution
    exec("which python3") || exec("which python") || print("Not found")
    
    # Complex chains
    int rc = exec("test -f config.txt") || exec("cp default.txt config.txt")
    if rc != 0
        print("Configuration setup failed")
        exit(1)
    end
}

Chains can be combined with I/O redirection:

# Pipe to print
exec("cat data.txt") | exec("grep ERROR") | print

# Pipe to file
dsk logs = disk("errors.log")
exec("journalctl -n 100") | exec("grep ERROR") ->> logs

12. Error Handling

Lyric provides comprehensive exception handling using try, catch (with optional type binding), and finally blocks.

12.1 Exception Types

def main() {
    # Simple catch
    try:
        n = int(input("Enter a number: "))
        print("Half:", n / 2)
    catch:
        print("Invalid input")
    finally:
        print("Done")
    fade
    
    # Typed catch with variable binding
    var items = [1, 2, 3]
    try:
        var x = items[10]
    catch IndexError as e:
        print("Caught IndexError:", e)
    catch KeyError as e:
        print("Caught KeyError:", e)
    catch:
        print("Caught unknown error")
    fade
}

Note: The raise keyword is reserved for future use. Currently, exceptions are raised by the runtime or through Python interop.


13. Regular Expressions

Lyric supports regular expressions using the regex() built-in function. Regex objects provide comprehensive pattern matching and text processing capabilities.

13.1 Syntax

# Basic regex pattern
rex pattern = regex("hello")

# Pattern with HTML tags (forward slashes don't need escaping in strings)
rex title_pattern = regex("<title>(.*?)</title>")

# Complex pattern with character classes
rex email_pattern = regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")

13.2 Escaping in String Patterns

Regex patterns are passed as strings, so standard string escaping rules apply:

# HTML tag pattern
rex div_pattern = regex("<div>(.*?)</div>")

# Pattern requiring backslash escape (for regex special chars)
rex word_boundary = regex("\\bword\\b")

# URL path pattern
rex path_pattern = regex("/api/v1/users/\\d+")

13.3 Supported Methods

Regex objects support the following methods:

Method Description
.match(text) Matches pattern at the start of text. Returns match object or None.
.search(text) Searches for pattern anywhere in text. Returns match object or None.
.findall(text) Returns a list of all non-overlapping matches in text.
.replace(text, replacement) Replaces all pattern matches with replacement string.

13.4 Match Objects

Match objects returned by .match() and .search() support group access:

Method Description
.group(0) Returns the entire matched string.
.group(n) Returns the nth captured group (1-indexed).

13.5 Examples

def main() {
    # HTML title extraction
    rex title_pattern = regex("<title>(.*?)</title>")
    var html = "<title>Hello World</title>"
    var match = title_pattern.search(html)
    if match:
        var title = match.group(1)
        print("Title:", title)  # Output: Title: Hello World
    end
    
    # Email validation
    rex email_pattern = regex("[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}")
    var text = "Contact us at support@example.com for help"
    var emails = email_pattern.findall(text)
    print("Found emails:", emails)
    
    # Find and replace
    rex phone_pattern = regex("\\d{3}-\\d{3}-\\d{4}")
    var text = "Call 555-123-4567 or 555-987-6543"
    var result = phone_pattern.replace(text, "[REDACTED]")
    print(result)  # Output: Call [REDACTED] or [REDACTED]
}

14. Shebang Support

Lyric scripts may begin with a shebang line for direct execution on Unix-like systems. The shebang line is safely ignored at runtime and does not affect script execution on any platform.

14.1 Supported Formats

#!/usr/bin/lyric

#!/usr/bin/env lyric

#!/usr/local/bin/lyric

14.2 Usage

Create the file.

#!/usr/bin/lyric
def main() {
    print("Hello from executable Lyric!")
}

Run the script.

# Make executable and run
$ chmod +x hello.ly
$ ./hello.ly
Hello from executable Lyric!

Note: The shebang line is optional and is transparently removed during script parsing. Scripts work identically with or without shebangs, and on both Unix and Windows platforms.

15. Built-in Functions

Lyric includes a set of built-in functions designed for common operations such as I/O, type conversion, iteration, and regular expressions. These functions are available globally and mirror the behavior of their Python equivalents where appropriate.

Note: Collection operations like len(), append(), keys(), and values() are now methods on arr and map objects (e.g., mylist.len(), mydict.keys()).

15.1 I/O Functions

Function Description
print(args) Prints one or more arguments to standard output, separated by a single space. Supports both function and bare syntax.
input(prompt="") Reads a line of input from standard input, displaying an optional prompt. Returns the entered string.

15.1.1 Print Dual Syntax

The print statement supports two equivalent forms:

# Function-style syntax
print("Hello, world!")
print("a", "b", "c")

# Bare syntax (no parentheses)
print "Hello, world!"
print "a", "b", "c"

Both forms produce identical output and generate the same Abstract Syntax Tree (AST). They can be used interchangeably based on personal preference.

15.2 Type Conversion Functions

Function Description
int(value) Converts value to an integer. For arr and map, returns element count.
float(value) Converts value to a floating-point number. For arr and map, returns element count as float.
str(value) Converts value to its string representation.
bin(value) Converts value to boolean using truthiness rules (0, empty β†’ False; non-zero, non-empty β†’ True).
god(value) Alias for bin(). Converts value to boolean.
arr(value) Converts value to an arr (list). Strings become character lists, maps become value lists.
tup(value) Converts value to a tup (immutable tuple). Arrays and lists become tuples; an existing tup is returned as-is.
map(value) Converts value to a map (dictionary). Arrays become indexed maps with string keys.

15.3 Utility Functions

Function Description
range(stop) Returns a list from 0 to stop-1.
range(start, stop) Returns a list from start to stop-1.
range(start, stop, step) Returns a list from start to stop-1 by step.
type(obj) Returns the name of the object's type as a string.
isinstance(obj, class_or_type) Returns true if obj is an instance of the specified class or type.

15.4 File Operations

Function Description
disk(path) Creates a dsk object for file operations at the specified path. Does not open the file immediately.
open(path) Opens the file at the specified path and returns an iterable over its lines. Files are read-only. (Legacy function)

15.5 Shell Operations

Function Description
exit(code=0) Terminates the program with the specified exit code. Defaults to 0 (success).
exec(command) Executes a shell command and returns its exit code as int. stderr is always merged into stdout across all capturing paths: -> (variable), ->> (file append), and | (pipeline). Can be used with I/O operators and chain operators (|, &&, ||).
getopts(short, long) Retrieves a command-line option by short name, long name, or both. Always takes two arguments β€” use None for the unused slot. Examples: getopts("v", "verbose"), getopts(None, "verbose"), getopts("v", None). For flags, returns true if present. For options with values (e.g., --file=path), returns the value. Returns false if option not provided.

15.6 Regular Expression Functions

Function Description
regex(pattern, flags="") Creates a regular expression object from the given pattern string. Optional flags parameter supports: i (case-insensitive), m (multiline), s (dotall), x (verbose). Returns a rex object that supports .match(), .search(), .findall(), .replace(), and .group() methods.

Note: These built-ins provide Lyric's foundational runtime functionality. Collection-specific operations are now methods on arr and map objects:


16. Standard Library

Lyric ships with a standard library that provides common utilities for file system operations, date and time, random numbers, and environment management. The standard library is imported using the import lyric statement and accessed via the lyric namespace. The name lyric and lyrical are reserved for the standard library implementation as it evolves.

import lyric

def main() {
    str cwd = lyric.pwd()
    print("Working directory:", cwd)

    tup d = lyric.date()
    print("Date:", d[0], "Time:", d[1])
}

The standard library has elevated import privileges β€” it can use any non-blacklisted Python module internally via importpy without requiring the --unsafe flag. The blacklist is still enforced to prevent access to CPython internals.


16.1 Time & Sleep

Function Return Description
lyric.sleep(seconds) β€” Pauses execution for the given number of seconds. Accepts fractional values (e.g., 0.1 for 100ms).
lyric.now() int Returns the current Unix timestamp as an integer (seconds since epoch).
lyric.date() tup Returns a tuple of two strings: ("YYYY-MM-DD", "HH:MM:SS") representing the current local date and time.
lyric.datefmt(fmt) str Returns the current local date/time formatted according to the given format string. Uses Python strftime format codes (e.g., "%Y-%m-%d", "%H:%M:%S", "%A").
import lyric

def main() {
    lyric.sleep(0.5)

    int timestamp = lyric.now()
    print("Unix time:", timestamp)

    tup d = lyric.date()
    print("Date:", d[0])
    print("Time:", d[1])

    str formatted = lyric.datefmt("%B %d, %Y")
    print("Today is:", formatted)
}

16.2 Random

Function Return Description
lyric.randflt() flt Returns a random floating-point number in the range [0.0, 1.0).
lyric.randint(low, high) int Returns a random integer between low and high (inclusive on both ends).
lyric.randarr(list) var Returns a random element from the given array.
lyric.seed(value) β€” Seeds the random number generator with the given integer value. Useful for reproducible results.
import lyric

def main() {
    lyric.seed(42)

    flt r = lyric.randflt()
    print("Random float:", r)

    int n = lyric.randint(1, 100)
    print("Random int:", n)

    arr colors = ["red", "green", "blue"]
    var pick = lyric.randarr(colors)
    print("Random color:", pick)
}

16.3 File System

Function Return Description
lyric.exists(path) god Returns true if the file or directory at path exists.
lyric.isfile(path) god Returns true if path is an existing regular file.
lyric.isdir(path) god Returns true if path is an existing directory.
lyric.mkdir(path) β€” Creates a new directory at path. Raises an error if the directory already exists.
lyric.rmdir(path) β€” Removes the empty directory at path. Raises an error if the directory is not empty.
lyric.rm(path) β€” Removes the file at path. Raises an error if the file does not exist.
lyric.ls(pattern) arr Lists directory contents. For "." and "..", returns all entries in that directory. For any other pattern, performs glob matching (e.g., "*.ly", "src/**/*.py").
lyric.cd(path) β€” Changes the current working directory to path.
lyric.pwd() str Returns the current working directory as an absolute path string.
import lyric

def main() {
    print("CWD:", lyric.pwd())

    if lyric.exists("output")
        print("output/ already exists")
    else
        lyric.mkdir("output")
        print("Created output/")
    end

    arr files = lyric.ls("*.ly")
    print("Lyric files:", files)

    lyric.rmdir("output")
}

16.4 Path Utilities

Function Return Description
lyric.join(path, file) str Joins two path components using the platform's path separator.
lyric.path(file) str Returns the absolute path for the given file or relative path.
lyric.base(file) str Returns the base name (file name) component of a path.
lyric.dir(file) str Returns the directory component of a path.
import lyric

def main() {
    str full = lyric.join("src", "main.ly")
    print("Joined:", full)

    str abs = lyric.path("main.ly")
    print("Absolute:", abs)

    str name = lyric.base("/home/user/project/main.ly")
    print("Base:", name)

    str parent = lyric.dir("/home/user/project/main.ly")
    print("Dir:", parent)
}

16.5 Environment

Function Return Description
lyric.env(name) str Returns the value of the environment variable name, or None if it is not set.
lyric.set(name, value) β€” Sets the environment variable name to value for the current process.
lyric.pid() int Returns the process ID of the current Lyric interpreter process.
import lyric

def main() {
    str home = lyric.env("HOME")
    print("Home:", home)

    lyric.set("MY_APP_MODE", "debug")
    print("Mode:", lyric.env("MY_APP_MODE"))

    int p = lyric.pid()
    print("PID:", p)
}

17. Current Scope of Lyric

Lyric is intentionally starting with a small, focused feature set. The goal at this stage is to provide a language that is easy to read, easy to reason about, and approachable for beginners, while establishing a stable foundation that can evolve over time.

As the project matures, more advanced language features may be introduced where they clearly add value and align with Lyric’s design philosophy.

At this time, the following features are not part of the current language design:

These features are not ruled out long-term, but they are intentionally excluded from the initial design to keep the language simple and focused.

17.1 Planned Development

Standard Library Expansion

Lyric ships with a native standard library (import lyric) providing file system operations, date and time utilities, random number generation, and environment management. The standard library will continue to expand incrementally over time with additional modules covering areas such as JSON handling, networking, string utilities, and similar tasks. As it matures, reliance on importpy for common use cases will be further reduced in favor of native implementations.

← Back