Syntax Style
Yulang syntax is designed for a free-paren style: function application, colon application, indentation, and user-defined operators carry much of the structure that parentheses would carry in C-like languages.
This page describes the idioms that make Yulang code read naturally, and the places where whitespace changes the parse.
Prefer Colon for a Large Final Argument
Use : when the final argument is a full expression or a block.
say: "hello"
format:
my name = "Yulang"
"hello, {name}"
run_console:
my answer = ask()
say answerThis keeps deeply nested calls from turning into nested parentheses.
-- Prefer this shape.
say: format: greeting name
-- Use parentheses only when the inner colon expression must be grouped.
say (format: greeting name)f x: body means that x is an ordinary argument and body is the colon argument. It parses like f x (body), not like (f x:) body.
f x: g y zWhitespace Is Syntax
Yulang has three call-like forms that look similar but parse differently.
f(x) -- C-style call
f (x) -- ML-style application of the parenthesized expression x
f: x -- colon applicationThe same rule applies to indexing.
xs[0] -- index
xs [0] -- apply xs to the list [0]Symbols make the whitespace rule especially visible.
f:foo -- colon application: f applied to foo
f :foo -- ML application: f applied to the symbol :fooWhen the meaning depends on this distinction, insert the space deliberately. Do not use f:foo as a compact spelling for passing a symbol.
Newlines End ML Application
Whitespace application is line-oriented. A newline stops the current ML application chain, unless another syntax form such as an indented colon block continues it.
f x y
f:
x
yUse : or a grouped expression when an argument expression should continue across a line boundary. Inside parentheses, a bare newline at the same grouping level can be read as another tuple/group item; indent continuation lines when the expression is meant to continue.
f:
g x
h y
f (g
x)Keep Method-Style Calls Receiver-First
Field and method selection use dot syntax. Selection itself is not a call; the selected value is then applied by the usual call syntax.
xs.length
xs.map f
text.splice(range 1 3, "bc")Prefer receiver-first dot calls for operations that conceptually belong to the left-hand value. Prefer module::name for constructors, effect operations, and names that are primarily module exports.
fs_err::not_found path
std::undet::each xsUse the Pipe for Left-to-Right Data Flow
The pipeline operator is |. It passes the left-hand value as the first argument to the right-hand call spine.
1 | add 2 -- add 1 2
xs
| map f
| filter pred| is left-associative and binds weaker than ordinary infix operators.
a + b | f -- (a + b) | fUse Indentation for Blocks
Indented blocks are the normal style for multi-line bodies.
my total xs =
my start = 0
fold add start xsBraces are useful when the block is small or needs to stay inside another expression.
my inc = \x -> { x + 1 }The last expression of a block is the block value.
Prefer Header Patterns for Functions
Bindings use patterns on the left-hand side. When the head is a name, following patterns become curried function arguments.
my add x y = x + y
my (head, tail) = pair
my area { width = 1, height = 2 } = width * heightPrefer this direct header style for small functions. Use an explicit lambda when the function value itself is the subject of the expression.
my mapper = \f xs -> xs.map fRecord patterns with defaults are the idiomatic way to spell small optional named arguments.
my box { width = 1, height = width } = width * height
box {}
box { width: 3 }Defaults are evaluated left-to-right, so later defaults may refer to earlier fields.
Keep case and catch Arms Vertical
Inline branches are useful for tiny expressions, but pattern-heavy code reads better with one arm per line.
case value:
nil -> fallback
just x -> x
catch action:
console::println_native text, k -> k ()
value -> valueUse guard clauses on the arm that owns the condition.
case n:
x if x < 0 -> "negative"
_ -> "non-negative"Put Extensions in with: Blocks
struct, enum, act, error, role, and type ... with: declarations create or extend a companion namespace. Put methods and nearby implementation details in the with: block when they conceptually belong to the declared thing.
type str with:
our s.splice r insert = std::str::splice s r insert
struct point { x: int, y: int } with:
our p.len2 = p.x * p.x + p.y * p.yThis keeps receiver-style APIs close to the type or effect that owns them.
with: is also useful as an expression-local extension point. Put helper bindings next to the expression that uses them when they are not part of a type's public companion API.
loop initial with:
our loop state =
if done state:
state
else:
loop: step statePut Constraints Near the Binding That Needs Them
Use where at the point where a type variable needs a role constraint.
my double(x: 'a): 'a =
where 'a: Add
x + xAvoid pushing constraints into unrelated helper bindings just to make a call typecheck. The constraint should live at the boundary whose behavior depends on the role.
Treat Operators as Imported Syntax
Operators are not all parser builtins. A module can define and export prefix, infix, suffix, nullfix, and lazy infix operators.
pub infix(+) 6.0.0 6.0.0 = add
pub lazy infix(and) 2.0.0 2.0.0 = \a -> \b -> ...
pub prefix(return) 1.0.0 = return_valueIf downstream files must parse an operator, export/import the operator syntax before those files are parsed. This is why public operator declarations usually belong near the top of a module or in prelude-like modules.
Word operators such as return, last, next, and redo follow the same operator model as symbolic operators. They should not be treated as magic parser exceptions in user code.
Use Lazy Operators for Short-Circuiting
Short-circuiting is written as lazy operator syntax rather than as a special case of the evaluator.
pub lazy infix(and) 2.0.0 2.0.0 = \a -> \b ->
if a():
b()
else:
falseBoth operands are provided as thunks, so the body decides whether to force each side. This makes and/or ordinary library-defined syntax with lazy evaluation behavior.
Add Type Annotations at Boundaries
Local code normally relies on inference.
my id(x) = xAdd annotations where they communicate a public contract, reduce ambiguity, or make an intended cast boundary explicit.
pub my id(x: 'a): 'a = x
my result: result str fs_err = fs_err::wrap:
read_text pathType variables are written as sigil identifiers such as 'a; they do not need a separate binder in ordinary function declarations.
Prefer Explicit State Syntax
Mutable or local-reference behavior is visually marked.
my $count = 0
&count = $count + 1Use this explicit form when mutation is intended, and keep ordinary my bindings immutable-looking.
Comments and Docs Are Different
Use // and /* ... */ for ordinary comments.
// local note
/* longer note */Use -- and --- ... --- only for documentation comments. They are parsed as documentation syntax and may be kept by tooling.
-- Documents the next declaration.
---
Longer documentation block.
---Summary
- Prefer whitespace application and
:over nested parentheses. - Use parentheses when grouping is the point, not as default punctuation.
- Prefer
my f x y = ...for ordinary function bindings. - Use record-pattern defaults for small optional named arguments.
- Keep pattern-heavy
caseandcatcharms vertical. - Put methods, attached impls, and expression-local helpers in the nearest natural
with:block. - Remember that
f(x)andf (x)are different. - Remember that
f:fooandf :fooare different. - Put exported operator syntax where importers can see it before parsing.
- Use indentation for real blocks and braces for compact inline blocks.