Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions docs/language-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,44 @@ must be ordered, within the byte length, and on UTF-8 codepoint boundaries.
Invalid bounds or boundaries trap at runtime. The owner remains responsible
for backing storage and is dropped exactly once.

## Iteration

`for item in source` supports built-in ranges and sequences. Their source is
evaluated once, including when a call produces a sequence.

Custom iteration uses an explicit call returning `?T`:

```peep
fn Produce(counter: &mut Counter, limit: i32) -> ?i32 {
if counter.value >= limit { return none; }
let value = counter.value;
counter.value = counter.value + 1;
return value;
}

for item in Produce(&mut counter, 10) {
println(item);
}
```

Every attempt evaluates the entire call, including its callee, receiver, and
arguments, using ordinary call evaluation order. A present result binds an item
of type `T`; `none` terminates the loop. The terminating attempt also evaluates
all arguments. `continue` starts another attempt; `break` and `return` do not.
No source or argument is implicitly captured for the lifetime of the loop.

Free functions, explicit method calls of any name (for example
`counter.Take(10)`), and pipe calls (`counter |> Produce(10)`) use canonical call
checking. There is no implicit `Next` method protocol or added runtime interface
protocol. Explicit calls retain ordinary dispatch, argument, move, borrow,
reference-provenance, effect, and cleanup semantics.

Custom loops provide one item binding, not an index. Maintain a separate counter
when needed. Bare optional values, function values without a call, and objects
with a `Next` method are not producers. Write the call explicitly. Optionals are
idempotent (`??T` is `?T`), so an optional result does not encode a separate
optional item layer.

## Generic Named Types

Structs, enums, interfaces, and transparent type aliases may declare type
Expand All @@ -116,16 +154,19 @@ and monomorphization are not part of current language surface.
## Optional Values And Flow Narrowing

`?T` contains either one `T` value or `none`. `none` is valid only where an
optional type is expected. A `T` value promotes to `?T`; this permits one-layer
promotion such as `?T` to `??T` when the outer optional is expected. Assigning
optional type is expected. A `T` value promotes to `?T`. Optionals are
idempotent: `??T` and `? ?T` mean `?T`, with no extra absence state. Explicit
syntactic nesting emits informational diagnostic `S0006`, asking to remove each
redundant `?`. Nesting revealed through aliases, generic substitution, or
wrapping an optional function result silently canonicalizes to `?T`. Assigning
or passing a whole optional to an explicit optional destination preserves its
carrier type instead of reading its payload.

Comparing a stable optional place with `none` establishes presence on one CFG
edge. `x != none` proves presence on the true edge; `x == none` proves presence
on the false edge. Reversed operands have identical meaning. Each proof unwraps
one optional layer, so nested optionals require one proof per layer. A proven
ordinary value use has payload type `T`; an unproven use retains `?T` and cannot
the optional carrier; redundant optional markers do not require extra proofs.
A proven ordinary value use has payload type `T`; an unproven use retains `?T` and cannot
stand in for `T`.

Stable places are variables, field and nested-field projections, constant-folded
Expand Down
1 change: 1 addition & 0 deletions internal/diagnostics/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const (
InfoRedundantComma = "S0003"
InfoRedundantPreludeImport = "S0004"
InfoRedundantGlobalQualifier = "S0005"
InfoRedundantOptional = "S0006"

// Warnings (W prefix)
WarnUnreachableCode = "W0001"
Expand Down
11 changes: 7 additions & 4 deletions internal/frontend/ast/clone.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ import "sync/atomic"

var nextSyntheticNodeID atomic.Uint32

// NewSyntheticNodeID shares one identity space across checked expansions and
// default-argument clones, disjoint from parser-assigned nodes.
func NewSyntheticNodeID() NodeID {
return NodeID(nextSyntheticNodeID.Add(1) | (1 << 31))
}

// SubstituteExpr clones an expression for call-site expansion. Parameter
// identifiers are replaced with their already-evaluated argument expressions;
// every cloned node gets a separate high-range ID so semantic caches cannot
Expand All @@ -18,11 +24,8 @@ func SubstituteExpr(expr Expr, substitutions map[string]Expr) (cloned Expr, defa
}
defaultClones = make(map[NodeID]NodeID)
argumentClones = make(map[NodeID]NodeID)
cloneID := func() NodeID {
return NodeID(nextSyntheticNodeID.Add(1) | (1 << 31))
}
newID := func(original NodeID, fromArgument bool) NodeID {
id := cloneID()
id := NewSyntheticNodeID()
if fromArgument {
argumentClones[id] = original
} else {
Expand Down
33 changes: 25 additions & 8 deletions internal/frontend/parser/parse_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ func (p *Parser) parseTypeExpr() ast.TypeExpr {
switch tok.Kind {
case token.AMP:
return p.parseRefTypeExpr()
case token.QUESTION:
case token.QUESTION, token.QQ:
return p.parseOptionalTypeExpr()
case token.ASTERISK:
return p.parseOwnedPtrTypeExpr()
Expand Down Expand Up @@ -147,18 +147,35 @@ func (p *Parser) parseRefTypeExpr() ast.TypeExpr {
}

func (p *Parser) parseOptionalTypeExpr() ast.TypeExpr {
start := p.consume(token.QUESTION, "expected '?' in optional type")
if start == nil {
return nil
markers := make([]source.Position, 0, 2)
for p.at(token.QUESTION) || p.at(token.QQ) {
marker := p.advance()
markers = append(markers, marker.Start)
if marker.Kind == token.QQ {
second := marker.Start
second.Advance("?")
markers = append(markers, second)
}
}
inner := p.parseTypeExpr()
if inner == nil {
return nil
}
return reg(p, &ast.OptionalType{
Inner: inner,
Location: source.NewLocation(p.filePath, start.Start, ast.EndOf(inner)),
})
for index := len(markers) - 1; index >= 0; index-- {
inner = reg(p, &ast.OptionalType{
Inner: inner,
Location: source.NewLocation(p.filePath, markers[index], ast.EndOf(inner)),
})
}
for _, start := range markers[:len(markers)-1] {
end := start
end.Advance("?")
p.diag.Add(diagnostics.NewInfo("redundant optional marker").
WithCode(diagnostics.InfoRedundantOptional).
WithPrimaryLabel(source.NewLocation(p.filePath, start, end), "remove redundant `?`").
WithNote("nested optional types are the same as a single optional type"))
}
return inner
}

func (p *Parser) parseOwnedPtrTypeExpr() ast.TypeExpr {
Expand Down
93 changes: 93 additions & 0 deletions internal/frontend/parser/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package parser

import (
"fmt"
"reflect"
"strings"
"testing"

Expand Down Expand Up @@ -2640,3 +2641,95 @@ func TestEmitterNewFormatNoSeverityPrefix(t *testing.T) {
t.Fatalf("expected location in output:\n%s", out)
}
}

func TestParseOptionalMarkersPreservesSyntaxAndTokens(t *testing.T) {
for _, spelling := range []string{"?i32", "??i32", "???i32", "????i32", "? ?i32", "?? ?i32"} {
t.Run(spelling, func(t *testing.T) {
src := "fn value() -> " + spelling + " { return none; }"
diag := diagnostics.NewDiagnosticBag()
stream := lexer.New("test.peep", src, diag).Tokenize()
original := append(stream[:0:0], stream...)
mod := New("test.peep", stream, diag).ParseModule()
if diag.HasErrors() {
t.Fatalf("unexpected parser errors: %s", diag.EmitAllToString())
}
if !reflect.DeepEqual(stream, original) {
t.Fatal("optional parsing mutated lexer tokens")
}
typ := mod.Stmts[0].(*ast.FnDecl).ReturnType
for index, char := range src {
if char != '?' {
continue
}
optional, ok := typ.(*ast.OptionalType)
if !ok || ast.StartOf(optional).Index != index {
t.Fatalf("missing source optional at %d: %#v", index, typ)
}
typ = optional.Inner
}
if named, ok := typ.(*ast.NamedType); !ok || named.Name != "i32" {
t.Fatalf("unexpected payload syntax: %#v", typ)
}
})
}
}

func TestParseRedundantOptionalSyntax(t *testing.T) {
for _, test := range []struct {
name string
source string
notes int
}{
{"double", "type Value = ??i32;", 1},
{"triple", "type Value = ???i32;", 2},
{"spaced", "type Value = ? ?i32;", 1},
{"spaced triple", "type Value = ? ? ?i32;", 2},
{"single", "type Value = ?i32;", 0},
{"alias", "type Maybe = ?i32; type Value = ?Maybe;", 0},
{"forward alias", "type Value = ?Maybe; type Maybe = ?i32;", 0},
{"generic", "type Maybe<T> = ?T; type Value = Maybe<?i32>; type Again = ?Value;", 0},
{"generic source once", "type Maybe<T> = ??T; type A = Maybe<i32>; type B = Maybe<bool>;", 1},
{"array boundary", "type Value = ?[2]?i32;", 0},
{"reference boundary", "type Value = ?&?i32;", 0},
{"field", "struct Box { value: ??i32 }", 1},
{"parameter and return", "fn Read(value: ??i32) -> ? ?i32 { return value; }", 2},
{"local", "fn main() { let value: ???i32 = none; }", 2},
{"function type", "type Callback = fn(value: ??i32) -> ??i32;", 2},
{"coalescing", "fn Read(value: ?i32) { let result = value ?? 7; }", 0},
} {
t.Run(test.name, func(t *testing.T) {
_, diag := parseTestModule(test.source)
if diag.HasErrors() != (test.name == "coalescing") {
t.Fatalf("unexpected error state:\n%s", diag.EmitAllToString())
}
notes := 0
positions := make(map[int]bool)
for _, item := range diag.Diagnostics() {
if item.Code != diagnostics.InfoRedundantOptional {
continue
}
notes++
if item.Severity != diagnostics.Info {
t.Fatalf("severity = %v, want info", item.Severity)
}
if len(item.Labels) != 1 || !strings.Contains(item.Labels[0].Message, "remove redundant `?`") {
t.Fatalf("missing removal advice: %#v", item)
}
loc := item.Labels[0].Location
if loc == nil || loc.Start == nil || loc.End == nil {
t.Fatal("missing source span")
}
if loc.End.Index != loc.Start.Index+1 || test.source[loc.Start.Index:loc.End.Index] != "?" {
t.Fatalf("span must select one redundant question mark: %v", loc)
}
if positions[loc.Start.Index] {
t.Fatalf("duplicate diagnostic at %v", loc)
}
positions[loc.Start.Index] = true
}
if notes != test.notes {
t.Fatalf("notes = %d, want %d:\n%s", notes, test.notes, diag.EmitAllToString())
}
})
}
}
6 changes: 6 additions & 0 deletions internal/ir/cfg/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type LoopEntryQuery func(ast.NodeID) bool
type BuildQueries struct {
MatchCases MatchCaseQuery
LoopGuaranteedEntry LoopEntryQuery
CheckedIterations map[ast.NodeID]*ast.BlockStmt
}

// BuildModule creates immutable control-flow topology from typed source syntax.
Expand Down Expand Up @@ -184,6 +185,11 @@ func (b *builder) buildStmt(stmt ast.Stmt, current *Block, scopeID ir.NodeID) *B
}
return join
case *ast.ForStmt:
if node.Iterable != nil {
if checked := b.queries.CheckedIterations[node.ID()]; checked != nil {
return b.buildStmt(checked, current, scopeID)
}
}
loopID := ir.NodeID(node.ID())
init := b.newBlock(BlockLoopInit, ast.LocOf(node))
bodyBlock := b.newBlock(BlockLoopBody, ast.LocOf(node))
Expand Down
2 changes: 1 addition & 1 deletion internal/ir/hir/lower/lower_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ func loweredRuntimeType(module *project.Module, t typeinfo.Type, seen map[*typei
if typ == nil {
return nil
}
return &typeinfo.OptionalType{Inner: loweredRuntimeType(module, typ.Inner, seen)}
return typeinfo.NewOptional(loweredRuntimeType(module, typ.Inner, seen))
case *typeinfo.ArrayType:
if typ == nil {
return nil
Expand Down
6 changes: 6 additions & 0 deletions internal/ir/hir/lower/module_lower.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,12 @@ func appendStmt(module *project.Module, scope *symbols.Scope, out *hir.Block, st
}
out.Stmts = append(out.Stmts, ifStmt)
case *ast.ForStmt:
if node.Iterable != nil {
if checked := module.Typechecking.CheckedIterations[node.ID()]; checked != nil {
appendStmt(module, scope, out, checked, returnType, ctx)
return
}
}
out.Stmts = append(out.Stmts, lowerForStmt(ctx, module, scope, node, returnType))
case *ast.MatchStmt:
evidence, found := module.Typechecking.Matches[node.ID()]
Expand Down
Loading
Loading