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
niland/or user classes (shape: Circle | Square,shape: Circle | Nil); - a plain (non-union) type naming a sealed
class (
shape: ShapewhereShapeissealed) — 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
sealednever triggers it —case n; when 0 ... endfor a plainn: Int, orcase shape; when Circle ... endforshape: ShapewhenShapeis an ordinary (non-sealed) class, both stay exactly as unchecked as they've always been. - A
sealedclass 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). Acasewith noelseover either of these compiles exactly as ifShapeweren'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 nowhensyntax 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 likex: Int | Stringis 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
nilscalarwhenvalue counts as covering a member, and so does a single, top-level, emptyCircle{}object pattern — an emptyCircle{}is already "anyCircleinstance" per this same section's own case/when semantics, so it coversCirclethe same way a barewhen Circledoes. 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 ofCircle; neither does an Array/Hash pattern (they never name a class at all, so there's no member for them to cover), an emptyCircle{}nested inside something else ([Circle{}, x]'s own top-level shape is an Array pattern), or a guardedwhen 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 onewhenare judged one at a time:when Circle{}, Square{}covers both classes, and so doeswhen Circle, Square, but inwhen Circle{r: 1}, Square{}onlySquareis covered, because theCirclealternative 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
whencovers its subclasses — awhennaming a class covers every required member that is that class or descends from it, since the pattern matches subclass instances at runtime. Forshape: Circle | Square(both< Shape),when Shapecovers both; for a sealedshape: Shape,when Shapecovers every direct subclass at once. Only the class hierarchy counts: a member that is not a descendant of the named class (orNil) still needs its own branch. - The compile error names every uncovered member, including
Niland 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. Ina&.b().c()a nilamakesa&.b()nil andcis then called on that nil, as in Kotlin; writea&.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 otherT | Nil. Inside the call the receiver is treated as its non-nil type. - A writer works too:
pet&.name = "Max"assigns only whenpetis 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.