d6 docs

Core syntax and values

Language reference · Next: Functions, closures, and calls

An object-oriented, end-delimited surface syntax that is expression-oriented and gradually typed — no separate "typed" object model, just optional annotations on top of dynamic dispatch. This document is a tour of the surface syntax; see Design and VM architecture for how it compiles and executes, Object model, Fibers, and I/O and native services for focused guides. HTTP is the packages/http package, installed with facet (Cuts): a server and client built on the I/O primitives below.

Running a program

d6 program.d6 runs the file and prints only what it writes with puts and the other output calls. The REPL and d6 -e CODE also print the value of the last top-level expression, so d6 -e '20 + 22' prints 42. Set DIAMOND_PRINT_RESULT to 1 to make a file run, or an executable from d6 build, print that value too; the test suite runs its cases that way.

Source files and imports

.d6 is d6's default and preferred source extension. .di files remain supported for backward compatibility, but use .d6 for new source files. Compiled caches for .d6 sources use .d6c.

For an extensionless require, d6 tries .d6 first and falls back to .di only if the .d6 file is absent. For example, put these three files in the same directory:

# helper.d6
def answer() = 42
# helper.di
def answer() = 99
# main.d6
require "helper"
answer()  # => 42: helper.d6 takes precedence over helper.di

An explicit require "helper.di" loads the legacy file instead, so answer() returns 99. If helper.d6 is absent, require "helper" also loads helper.di.

Basics

x = 0
sum = 0
while x < 10
  x = x + 1
  if x > 5
    sum = sum + x
  end
end

if/while/begin and friends are all expressions — the last line of a block is its value. Blocks are end-delimited throughout; there are no braces. next/break work inside loops.

A one-line loop puts the condition after the statement: i += 1 while i < 10 and i -= 1 until i == 0. As in the block form the condition is tested first, so the statement may never run, and the loop evaluates to nil. The statement must be an assignment or an expression (not return, break, next, a declaration or a block-form if/while), only one modifier is allowed per statement, and begin ... end while cond (a body that runs first) is not supported.

Strings are double-quoted, with #{expression} interpolation and the escapes \n, \r, \t, \0, \e (escape), \", \\, \#, \xHH (one byte), and \uHHHH or \u{H...} (a Unicode code point, stored as UTF-8): "\x89PNG\r\n", "caf\u00e9", "\u{1F600}".

Case/when

def classify(n)
  case n
  when 0
    "zero"
  when 1, 2, 3
    "small"
  else
    "big"
  end
end
classify(2)   # => "small"

case SUBJECT matches each when pattern against the subject in turn, an expression like if — the matched branch's last line (or the else branch, or nil if nothing matches and there's no else) is the whole expression's value. A when can list several comma-separated values (when 1, 2, 3); matching short-circuits left to right, so a later value in the list is never even evaluated once an earlier one in the same when already matched. when VALUE then BODY works the same as if COND then BODY, for a one-line branch.

Matching follows ===-style case semantics: a Range tests numeric inclusion, a Regexp searches a String subject, and a user class name matches instances of that class or its subclasses. Other patterns use equality, with the pattern as receiver, so a user instance can customize matching through def ==(value). Native type names such as String are not class-pattern values because d6's native types are not reified classes. A subject-less boolean form (Ruby's case with no expression, where each when's own value is tested for truthiness instead of compared against a subject) is also supported — see below.

Array patterns can match a nested shape and bind lowercase names:

case event
when ["score", [1..100, points], Player, _]
  record(points)
else
  reject(event)
end

Every bracketed level must be an Array of exactly the written length unless it contains one rest binding. A trailing [head, *tail] captures the remaining elements; a middle [head, *middle, tail] reserves the written suffix and captures everything between prefix and suffix. The rest binding accepts zero or more elements and receives them as a fresh Array; *_ accepts and discards the same span. Only one rest binding is allowed per Array level. Nested entries use the same literal, Range, Regexp, class/subclass, and custom-equality matching described above. A lowercase bare name binds that element for the branch; _ ignores it. Prefix an existing lowercase local with ^ to compare the element against its current value instead of rebinding it, as in [^expected_kind, payload]. Pins work at any nested level and may reference a captured local. An undefined or non-lowercase pin is a compile error. Bindings are written only after the complete pattern matches, so a failed later element cannot partially overwrite locals. An Array binding pattern must be the sole pattern in its when clause; comma-separated alternatives remain available for non-binding patterns.

Hash patterns require each written key while allowing additional keys:

case event
when {"kind": ^expected_kind, "payload": {"score": score}}
  record(score)
else
  reject(event)
end

Keys are ordinary expressions and use normal Hash key equality. Values support the same literals, nested Array or Hash patterns, lowercase bindings, _ wildcards, and ^local pins as Array patterns. Missing keys fail the pattern even when the requested value pattern would match nil. Empty {} matches any Hash. A trailing **remaining binding receives every unmatched entry in a fresh Hash; **_ accepts and discards those entries without allocating a Hash. The rest binding must be last and works at nested levels. Bindings remain failure-atomic.

Object patterns guard by class and extract values through public readers:

case event
when ScoreEvent{player: Player{name: name}, points: ^expected_points}
  record(name)
else
  reject(event)
end

The subject must be an instance of the named class or a subclass. Each written reader must exist on that class, accept zero arguments, and be public; otherwise the pattern is rejected at compile time. Reader results support nested object, Array, and Hash patterns plus bindings, _, and ^local. Empty Class{} is a class-only guard. Bindings commit only after every reader result matches.

Any when pattern can carry an if guard:

case event
when {"score": score, **metadata} if score > minimum && metadata.length() > 0
  accept(score, metadata)
else
  reject(event)
end

Collection bindings are visible inside the guard, but remain provisional. The guard runs only after the structural pattern matches. A false guard falls through to the next when without overwriting existing locals or creating new binding values. Bindings commit immediately before the selected branch body.

Comma-separated collection patterns may serve as alternatives when every alternative binds exactly the same set of names:

case message
when ["score", value], {"score": value} if value > minimum
  accept(value)
else
  reject(message)
end

Alternatives match from left to right and stop after the first structural match. Binding order may differ, but missing or additional binding names are a compile error. Values from the selected alternative flow through shared provisional registers, so one guard and one atomic commit path serve the whole clause. A false guard does not retry later alternatives.

Omitting the case subject creates a boolean case:

case
when score >= 90
  "excellent"
when score >= 70, override?
  "passing"
else
  "retry"
end

Each when expression is tested directly for truthiness. Comma-separated expressions short-circuit from left to right, and ordinary if guards remain available. Array and Hash spellings in a subjectless clause are ordinary literals, not binding patterns; pattern bindings require a case subject.

Exhaustiveness checking

A case whose subject has a known closed type must cover every member, either with an else or with an unguarded when naming each one, or it's a compile error. There are two independent ways a subject's type counts as closed:

  • an explicit union made entirely of nil and/or user classes (shape: Circle | Square, shape: Circle | Nil);
  • a plain (non-union) type naming a sealed class (shape: Shape where Shape is sealed) — every direct subclass of it is required instead of an explicit union's own members.
def area(shape: Circle | Square)
  case shape
  when Circle
    3.14159 * shape.radius() * shape.radius()
  end
  # error: case is not exhaustive over its subject's known closed type;
  # missing: Square; add the missing branch or an 'else'
end

Adding when Square ... end (or an else) fixes it. The sealed-class form reads identically, just without writing the union out by hand:

sealed class Shape
end
class Circle < Shape
  # ...
end
class Square < Shape
  # ...
end
def area(shape: Shape)   # plain type, not a union -- Shape being sealed
  case shape              # is what makes this exhaustible at all
  when Circle
    3.14159 * shape.radius() * shape.radius()
  when Square
    shape.side() * shape.side()
  end
end

Both forms only ever add a compile error to code that previously compiled and silently returned nil from the uncovered path — they never change what a covered case does, and neither fires at all outside its own specific shape of subject:

  • A single, non-union type naming a class that isn't sealed never triggers it — case n; when 0 ... end for a plain n: Int, or case shape; when Circle ... end for shape: Shape when Shape is an ordinary (non-sealed) class, both stay exactly as unchecked as they've always been.
  • A sealed class with zero, or more than 8, direct subclasses is left unchecked too, not an error either way — zero is more likely "this hierarchy isn't built out yet" than an intentional 0-variant type, and more than 8 exceeds the same fixed member-count ceiling every union in d6 already has (DIAMOND_MAX_UNION_TYPES). A case with no else over either of these compiles exactly as if Shape weren't sealed at all.
  • A union containing any native scalar/container type (Int, String, Array, ...), interface, or generic type variable is never checked — there is no when syntax that can prove "this whole native type is covered" (native type names aren't class-pattern values, unlike a user class name), so a case like x: Int | String is left exactly as unchecked as today rather than either inventing new pattern syntax or producing false positives. A union containing a sealed class as one of several members does not recursively expand into that member's own subclasses either — only the union's own explicit members are required in that case.
  • A bare class-name or nil scalar when value counts as covering a member, and so does a single, top-level, empty Circle{} object pattern — an empty Circle{} is already "any Circle instance" per this same section's own case/when semantics, so it covers Circle the same way a bare when Circle does. So does an object pattern whose readers are all plain bindings or _ (Circle{radius: r}), since it constrains nothing. One with a literal, pin, or nested pattern among its readers (Circle{radius: 0}) does not, since it only matches a subset of Circle; neither does an Array/Hash pattern (they never name a class at all, so there's no member for them to cover), an empty Circle{} nested inside something else ([Circle{}, x]'s own top-level shape is an Array pattern), or a guarded when Circle if .../when Circle{} if ... clause (the guard could reject the match at runtime, so the type isn't unconditionally covered). Comma-separated alternatives in one when are judged one at a time: when Circle{}, Square{} covers both classes, and so does when Circle, Square, but in when Circle{r: 1}, Square{} only Square is covered, because the Circle alternative matches just some circles. (Alternatives must bind the same names, so use _ for a reader the other alternatives don't have: when Coin{cents: _}, Select{}.) Applies identically to both forms.
  • A superclass when covers its subclasses — a when naming a class covers every required member that is that class or descends from it, since the pattern matches subclass instances at runtime. For shape: Circle | Square (both < Shape), when Shape covers both; for a sealed shape: Shape, when Shape covers every direct subclass at once. Only the class hierarchy counts: a member that is not a descendant of the named class (or Nil) still needs its own branch.
  • The compile error names every uncovered member, including Nil and direct subclasses of a sealed class.

Ternary

x = 5
x > 3 ? "big" : "small"                   # => "big"
x == 1 ? "one" : x == 2 ? "two" : "other" # => "other", right-associative

cond ? a : b binds looser than every binary operator (including ../&&/||) but tighter than assignment, matching Ruby's own precedence — x = a ? b : c reads as x = (a ? b : c), and true || false ? "t" : "f" reads as (true || false) ? "t" : "f". Nested ternaries in the false branch (as above) right-associate the way Ruby's own does. Both branches are plain expressions, not statement blocks — same as everywhere else d6 takes an expression, this rules out x = 5 as a branch, but not another ?:, a method call, or a literal.

Line continuation follows the same "trailing operator" convention every other multi-line expression in d6 uses — put the ?/: at the end of the line, not the start:

x > 3 ?
  "big" :
  "small"

Method chains across lines

A line that starts with . continues the previous expression, so a long chain can be written one call per line. Blank lines and # comment lines may sit between the links:

names = users
  .select() do |user| user.active?() end
  # newest first
  .sort_by() do |user| user.created_at() end
  .map() do |user| user.name() end

.. at the start of a line is still a range, not a method call. A line that starts with &. continues the chain the same way.

Safe navigation

receiver&.name(args) calls name unless receiver is nil; then the arguments are not evaluated and the whole call is nil:

pets = {"rex": "Rex"}
pets["rex"]&.upcase()        # => "REX"
pets["tom"]&.upcase()        # => nil
false&.to_s()                # => "false", only nil short-circuits
  • The receiver is evaluated once.
  • Skipped arguments leave existing locals unchanged, including assignments in those arguments. When the receiver may be nil, local type facts after the expression account for both the skipped and executed paths.
  • Each &. covers one call. In a&.b().c() a nil a makes a&.b() nil and c is then called on that nil, as in Kotlin; write a&.b()&.c() to pass nil along the chain.
  • The result type is the method's return type joined with Nil, so a nil guard (if name != nil) narrows it like any other T | Nil. Inside the call the receiver is treated as its non-nil type.
  • A writer works too: pet&.name = "Max" assigns only when pet is not nil. A call with a block works the same way: names&.map() do |n| n.upcase() end.

Endless methods

def name(params) = expr defines a method whose body is one expression, with no end. The expression may begin on the next line (blank and comment lines may come first), which keeps a long body readable:

def total_price(items, tax) =
  items.map() do |item| item.price() end.sum() * (1 + tax)

The parentheses after the name are required, as for any def.

The body may also be a raise, which suits a method that subclasses must override. A postfix condition makes it a guard that returns nil when the condition is false:

class Shape
  def area() -> Float = raise ArgumentError.new("subclass must define area")
end
def check(x: Int) = raise ArgumentError.new("negative") if x < 0

begin
  Shape.new().area()
rescue error: ArgumentError
  puts(error.message())  # prints: subclass must define area
end
check(3)  # => nil

Compound assignment

x = 10
x += 5    # 15
x -= 3    # 12
x *= 2    # 24
x /= 4    # 6
x %= 5    # 1

count = nil
count ||= 0    # count was nil, so this assigns: count is now 0
count ||= 99   # count is already 0 (truthy-or-falsy check, not nil check
               # -- see below), so this is a no-op: count stays 0

flag = true
flag &&= check_something()  # only evaluates/assigns if flag is truthy

+=/-=/*=//=/%= are pure sugar for x = x <op> y — same semantics, same runtime errors (x /= 0 raises ZeroDivisionError exactly like plain / does), same operator-overload dispatch for an Instance target. ||=/&&= are genuinely short-circuit, not sugar for x = x || y/x = x && y evaluated unconditionally: the right-hand side is only evaluated (and the assignment only happens) when the existing value doesn't already decide the outcome — x ||= y skips y entirely when x is already truthy, x &&= y skips y entirely when x is already falsy. Truthy/falsy here means d6's ordinary truthiness (only nil and false are falsy), not specifically "is nil" — count ||= 99 above is a no-op once count is 0, since 0 is truthy, the same way plain if count would treat it.

The target can be a plain local, an @ivar, or a @@cvar — same three targets plain = and multiple assignment accept. Indexed targets (arr[i] += 1, hash[k] ||= default) are also supported — see "Indexed compound assignment" under Ranges below.

Writer-call assignment sugar

class Author
  attr_accessor name: String
end

ada = Author.new()
ada.name = "Ada Lovelace"   # sugar for ada.name=("Ada Lovelace")
puts(ada.name())            # => "Ada Lovelace"

Attributes are declared with the name and its type, attr_accessor name: String, not with a Symbol (attr_accessor :name is an error that says so).

receiver.attr = value is pure sugar for the writer-method call receiver.attr=(value) — the same call either spelling compiles to, reaching any writer method, hand-written or attr_accessor-synthesized. Because it's just a re-spelling of an ordinary method call rather than new assignment semantics, the expression's own value is whatever the writer method actually returns (an attr_accessor-generated writer returns the value it was just given, but a hand-written one is free to return anything). The receiver can be any expression, not just a local — Author.new().name = "Ada" and book.author().name = "Ada" both work — and the right-hand side is one ordinary expression, parsed exactly like plain assignment's own right-hand side (so attr = [1, 2, 3] is the array literal, not an attempt at generic arguments).

Multiple assignment

def min_max(values)
  [values.min(), values.max()]
end

lowest, highest = min_max([3, 1, 4, 1, 5])
[name, [x, y]] = ["origin", [0, 0]]
[head, *tail] = [1, 2, 3] # tail is [2, 3]

t1, t2, ... = expr unpacks a single Array-valued expression across several targets in one statement — a natural fit for this codebase's own convention of returning [a, b, ...] from a function that logically has more than one result. Targets can be plain locals, @ivars, or @@cvars (freely mixed), and behave exactly like their single-target counterparts otherwise — a target that already exists is reassigned, not redeclared; a new local is defined; a captured local is written through its cell.

The right-hand side must actually be an Array (a Hash, for instance, raises TypeError rather than being silently — and almost certainly wrongly — read by integer key) and its length must exactly match the number of targets, or it raises ArgumentError. This is stricter than Ruby's own lenient multiple assignment (which pads missing targets with nil and silently drops extra values) — deliberately, to match this language's existing preference for a clear, immediate error over quietly doing something the call site probably didn't intend.

Bracketed patterns can nest to unpack structured Array results: [head, [left, right]] = value. Every bracketed level must independently be an Array of exactly the written length. Brackets are optional for the outermost legacy form, so head, [left, right] = value is equivalent. Leaves retain the same local/@ivar/@@cvar behavior at any depth. The complete nested shape is validated before any leaf is assigned, so a rescued inner type/length failure cannot leave earlier targets half-updated. Any bracketed level may contain one rest target ([head, *middle, tail]). It requires the fixed prefix and suffix, then assigns a fresh Array containing the intervening elements, including an empty Array when the two fixed regions touch. The unbracketed outer form supports the same middle-rest placement.

Hash patterns use required keys and may capture unmatched entries: {"name": name, "meta": {"id": id}, **remaining} = value. The right-hand side must be a Hash; each written key must be present, while extra keys are tolerated. **remaining receives a fresh Hash containing only extra entries. Nested Array and Hash patterns validate before any leaf store, so failures are failure-atomic.

Comma-separated literal right-hand sides are supported: a, b = 1, 2 is equivalent to destructuring [1, 2], with each expression evaluated once from left to right. Indexed leaves are supported at any depth, including chained indices: [head, matrix[row][column]] = value. Receivers and indices are evaluated left to right, while the final indexed writes remain delayed until the complete pattern validates. Member-writer leaves are also supported: [record.name, grid[row].score] = value. Intermediate members in a target path are invoked as zero-argument readers; the final member is invoked as its writer. All final writer calls remain delayed until validation succeeds.

Numbers

x = 3
y = 2.5
x + y        # => 5.5, Int auto-promotes to Float
x == 3.0     # => true, Int and Float compare by value
-y           # => -2.5
to_f(x)      # => 3.0
to_i(y)      # => 2

Int has no fixed size limit: arithmetic that overflows 64 bits transparently promotes to an arbitrary-precision representation, the same auto-promoting behavior as Ruby, Python, or Lisp. A literal larger than 64 bits (18446744073709551615, 0xFFFF_FFFF_FFFF_FFFF_FFFF) is an arbitrary-precision Int too. Float is an IEEE-754 double. Float literals need a digit on both sides of the . (2.5, not .5 or 2.), so 5.abs() still parses as a method call rather than a float literal. Both accept _ digit separators (1_234.567_8). Int literals can also be written in hexadecimal, binary, or octal: 0xFF, 0b1010_1010, 0o17. ** is exponentiation, binding tighter than unary minus and right-associative (-2 ** 2 is -4, 2 ** 3 ** 2 is 512). Exponent notation (1e10, 1.5e-3, 2E+7) is also accepted, with or without a . fraction, and always produces a Float; an e/E not followed by a valid exponent (no digits, e.g. 5e) is left for the next token rather than erroring, the same "peek before committing" rule the . fraction already uses.

Mixing Int and Float in arithmetic or comparisons auto-promotes the Int side to Float — 3 + 2.5 and 3 == 3.0 both behave as you'd expect from Ruby or Python. Division by 0.0 follows IEEE-754 rather than raising: 1.0 / 0.0 is Infinity, -1.0 / 0.0 is -Infinity, 0.0 / 0.0 is NaN, and NaN compares unequal to everything, including itself. Integer division by zero still raises a rescuable ZeroDivisionError. Integer overflow (including negating the smallest representable 64-bit Int) no longer raises — it promotes to an arbitrary-precision Int instead, transparently; is Int and arithmetic both keep working the same way on a promoted value as on any other Int.

Int / is floored division, matching Ruby (not C's truncating /): the quotient rounds toward negative infinity, so -7 / 2 is -4 and 7 / -2 is -4, and (a / b) * b + a % b == a always holds. Arbitrary-precision Ints round the same way.

% is floored modulo, matching Ruby (not C's truncating %): the result always takes the divisor's sign, not the dividend's — -7 % 3 is 2, not -1. Works on Int/Float and any mix of the two (same promotion rule as the other arithmetic operators); Int % 0 raises ZeroDivisionError, Float % 0.0 is NaN like IEEE-754 division above. User-overloadable like +/-/*// (see "Operator overloading" below). Arbitrary-precision Ints work on either side, with the same floored result (-(10 ** 30) % 7 is 6, not -1).

to_f/to_i convert explicitly between the two. to_i rejects NaN and Infinity with a rescuable RangeError (there's no finite integer to convert to), but a finite Float outside 64-bit range now promotes to an arbitrary-precision Int rather than raising, consistent with arithmetic overflow. Type annotations stay strict even though arithmetic auto-promotes: a parameter declared x: Float rejects an Int argument outright — pass it through to_f first.

Floats print with a forced .0 when they'd otherwise look like an integer (3.0, not 3), so they stay visually distinct from Int in puts output and string interpolation. Formatting always finds the shortest decimal that round-trips back to the exact same value (an iterative %g-precision search against strtod, not a fixed digit count), so every Float prints exactly and unambiguously — including values that need the full 17 significant digits a double can carry, which a naive fixed-precision format can silently get wrong.

Both numeric types have conversion methods, and Float has rounding:

42.to_s()            # => "42", the same text interpolation produces
2.5.to_s()           # => "2.5"
3.to_f()             # => 3.0
2.9.to_i()           # => 2, truncating toward zero
7.abs()              # => 7; 1.5.abs() is 1.5
2.7.floor()          # => 2
2.1.ceil()           # => 3
2.5.round()          # => 3, halves round away from zero
2.345.round(1)       # => 2.3, a Float
1234.5.round(-2)     # => 1200.0
3.14159.floor(2)     # => 3.14; ceil(digits) likewise

floor, ceil, round(), and to_i return an Int and follow the same rules as the global to_i: NaN and Infinity raise a rescuable RangeError, and a value beyond 64 bits promotes to an arbitrary-precision Int. round(digits), floor(digits) and ceil(digits) take an Int from -15 to 15 and return a Float;

because it scales by a power of ten, results inherit ordinary binary floating-point imprecision. to_s (including to_s(base) for bases 2 to 36), to_i, to_f, and abs also work on Ints that have promoted past 64 bits, as do the bitwise operators &, |, ^, << and >>; "ff...".to_i(16) parses digits past 64 bits into one. A minus written right against a number literal is part of the literal, as in Ruby: -2.5.floor() is (-2.5).floor(), -3. Written with a space (- 2.5.floor()) or in front of anything else (-x.floor()), it negates the whole call, and -2 ** 2 is -(2 ** 2), -4.

Int has three block-consuming iteration methods, dispatched natively straight to ordinary prelude functions rather than through any class — Int isn't a class and can't be reopened:

5.times() do |i|
  puts(i)          # 0, 1, 2, 3, 4
end
1.upto(5) do |i|
  puts(i)          # 1, 2, 3, 4, 5
end
5.downto(1) do |i|
  puts(i)          # 5, 4, 3, 2, 1
end

Each returns the receiver (matching Ruby), not the block's own result or a collected array — the loop itself is the point.

Ranges

(1..5).first()         # => 1
(1..5).last()          # => 5
(1..5).include?(5)     # => true
(1...5).include?(5)    # => false, exclusive of the end
(1..5).length()        # => 5
(1...5).length()       # => 4
(5..1).length()        # => 0, reversed ranges are just empty

sum = 0
(1..5).each() do |x| sum += x end
sum                    # => 15

1..5 (inclusive of 5) and 1...5 (exclusive of 5) both desugar directly at parse time into Range.new(1, 5, false) / Range.new(1, 5, true) — Range is a plain class in lib/core.d6, the same "not a native object" precedent StringBuilder already establishes, not a new VM value kind. No new bytecode opcode exists for it either: the desugaring reuses the exact same NEW instruction sequence ClassName.new(...) already produces.

../... bind looser than every other binary operator, including &&/||, matching Ruby's own precedence table: 1..n+1 reads as 1..(n+1), and a > 0 .. b < 10 reads as (a>0)..(b<10).

1.. (nothing after the dots) is an endless range, written as a Range whose end is nil: arr[1..] is everything from index 1 on, case n when 90.. then ... matches anything from 90 up, (1..).each runs forever until a break, and include?, first(n) and endless? work. Anything that needs the whole range (to_a, length, last, step, reverse_each) raises RangeError. A negative bound in an Array or String index counts from the end: arr[1..-1] drops the first element, arr[0...-1] the last.

Range includes Enumerable, so .select/.count/.any?/.all?/ .map/.reduce and the rest of Enumerable's methods all work on a range the same way they do on any other Enumerable-including class (see "Collections" below).

Scope, deliberately: Range is Int-only for v1 (start must be an Int, end an Int or nil) — constructing one with Float or any other type raises a TypeError, the same cut array_sort already makes elsewhere. Range-based indexing/slicing works for Array (not Hash — real Ruby doesn't support Hash#[] with a Range either, [] there is always plain key lookup): arr[1..3] returns a fresh Array of the selected elements, inclusive or exclusive matching the range's own ../.... The start is bounds-checked (arr[10..20] on a 3-element array raises IndexError, even though start==array.length() is valid and simply yields an empty Array), but the implied length is silently clamped to whatever's actually available — arr[1..100] is not an error. Writing a slice (arr[1..3] = [a, b, c]) requires the replacement to have exactly as many elements as the range covers (after the same clamping) — no Ruby-style grow/shrink splice in this first version; a length mismatch raises TypeError and leaves the array completely untouched, checked before writing any element back.

Indexed compound assignment against a plain Int index is supported, though: arr[i] += 1, h[k] -= 1, and the rest of +=/-=/*=//=/ %=/||=/&&= all work against an Array element or Hash value, the same as plain arr[i] = v — the index expression is evaluated exactly once either way.

inspect

value.inspect() is to_s() for debugging: it shows what a value is rather than how it prints. It works on every value.

["a", "b, c"].to_s()       # => "[a, b, c]"      (ambiguous)
["a", "b, c"].inspect()    # => "[\"a\", \"b, c\"]"
"line\n".inspect()         # => "\"line\\n\""   (quoted, escapes shown)
:ok.inspect()              # => ":ok"
{"k": [1, nil]}.inspect()  # => "{\"k\": [1, nil]}"

Strings are double-quoted with \", \\, \n, \t, \r and \xNN for other control bytes. A Symbol keeps its colon. Numbers, nil and booleans print as to_s does. Collections use the same source-like layout as to_s, with each element inspected.

An instance shows its fields, #<Point x=1, y=2>, and a cycle shows #<Node ...>. A class can define its own inspect and every collection that holds it will use that. inspect ignores a class's to_s; define both if you want both.

Symbols

status = :ok
status == :ok        # => true
status == "ok"        # => false, different kind entirely
to_sym("ok") == status # => true

:name is a Symbol literal — a small, immutable, name-like value distinct from String, useful for enum-like constants and Hash keys. Symbol equality and hashing are by byte content, exactly like String (:ok == :ok is true even across two separately-created :ok literals) — unlike Ruby, where Symbol is interned and pointer-equal. d6 deliberately scoped Symbol this way: interning would mean every Symbol ever created lives for the rest of the process (Ruby's own tradeoff), and this codebase defers that kind of complexity until profiling shows it's actually worth it (see docs/internal/design.md's note on why NaN-boxing is likewise deferred).

to_sym(string) converts a String to a Symbol; the reverse direction goes through puts/string interpolation/a class's to_s method, all of which print a Symbol as its bare name with no leading colon — matching Ruby's to_s/puts convention. This keeps to_sym and printing true inverses of each other: to_sym("#{:ok}") == :ok. The practical tradeoff is that a Symbol and a same-named String print identically — Ruby has this same tradeoff for the same reason. When you need to tell them apart, use inspect().

A colon starts a Symbol literal only when it isn't immediately glued (no space) onto the end of a preceding identifier, digit, or closing )/]/}/" — so a parameter type annotation (x:Int), a hash-literal separator ({"a":b}), and a rescue e:Type binding all keep meaning exactly what they meant before Symbols existed, even written with no space.

Edit this page on GitHub