Skip to content

Sprig v0.8.0-beta.1

Pre-release
Pre-release

Choose a tag to compare

@github-actions github-actions released this 08 Oct 16:25
· 1 commit to main since this release
1352632

Sprig v0.8.0-beta.1

The release body is this file as of the tag. The latest published SDK is
v0.7.1-beta.1; the version number follows the
release and versioning policy: a new MINOR, because the
release requires JDK 21 and ships a large feature set.

Sprig v0.8.0-beta.1 is the release in which the language grows for the
first time since v0.7: if as an expression, type arguments worked out from a
call's arguments, contract classes for open polymorphism without a new keyword,
one-line classes, function references, error classes, and read-only views of
mutable collections. Java interop can extend a Java class, reads nullability
annotations, and infers a Java method's own type parameters. Concurrency moves
into the standard library as structured scopes over JDK 21 virtual threads, and
packages are published to the repository's registry through pull requests
validated by CI. The compiler is 0.8.0-beta.1; the language version remains
0.8-dev. JDK 21+ is required. Linux and macOS are release-supported;
Windows remains an experimental, non-blocking preview. It is an experimental
Beta, not a promise of production stability or a frozen API.

Language

if as an expression

if is an expression wherever an expression match is accepted: let and var
initializers, assignments, return, throw, lambda bodies and the branches of
other if or match expressions. Each branch is one expression on its own
indented line and else is required. At the start of a statement, if is
still the statement.

let size = if count > 9:
    "big"
elif count > 0:
    "some"
else:
    "none"

Typing and narrowing are the expression match's and the if statement's: a
contextual expected type when there is one, a null branch makes the result
nullable, conditions are Bool, only the chosen branch is evaluated. A missing
else is one SPR-SYNTAX-ERROR that no longer stops the rest of the file from
being checked, so the editor keeps hover, definition and the outline while the
else is written. The a if c else b and c ? a : b spellings get targeted
messages.

Every elif and the else know the earlier conditions

In if statements and if expressions alike, each elif (its condition included)
and the else see every earlier condition false, and the statements after a
chain whose earlier branches all exit see them false too. A name declared T?
may be compared with null while it is narrowed. See the upgrade section:
two patterns that compiled before take a narrower type.

Type arguments inferred from a call's arguments

Calls of generic Sprig functions, constructors of generic classes and
constructors of generic variant cases with a payload infer the type arguments
they leave out, from their arguments only:

import "@std/lists.spr" as lists
let names: List[String] = ["Ada", "Grace", "Barbara"]
print(lists.sorted(names))                                   # T = String
print(lists.group_by(names, fn(n: String) => n.length()))    # T = String, K = Int

Written [Type] arguments still win and are all or none. Nothing is inferred
from the expected type, the assignment target or the result: lists.first([]),
a payloadless case such as Option[Int].None and a parameter that no argument
mentions still write their arguments, and the SPR-TYPE-GENERIC-ARGS-REQUIRED
hint writes the call with the inferred arguments filled in. requires X: Comparable is checked on the inferred argument.

Contract classes

A class whose methods all have no body, and no fields, is a contract. A class
declares that it fulfils one with conform, and its objects then go wherever
the contract type is expected. There is no interface keyword and no
inheritance between Sprig classes.

class Sink:
    func write(line: String) -> Unit
    func flush() -> Int

class Console:
    var written: Int = 0
    func write(line: String) -> Unit:
        written += 1
        print(line)
    func flush() -> Int:
        return written

conform Console to Sink

func drain(lines: List[String], sink: Sink) -> Int:
    lines.forEach(sink.write)
    return sink.flush()

Every method of the contract is matched exactly (SPR-CONFORM-MEMBER), the
class throws no more than the contract declares (SPR-CONFORM-EFFECTS), a
contract in an imported module is named alias.Contract, and a contract cannot
be constructed, declare fields or mix methods with and without bodies
(SPR-CLASS-ABSTRACT). A contract value is never converted back to the class.
A generated contract is a Java interface, so Java code sees the conforming
class as implementing it.

Three rules are settled for the 0.8 language, each with a targeted
diagnostic that names the alternative: a contract is never generic
(SPR-CLASS-ABSTRACT; one contract per element type, or a generic class
holding the operation as a fn field), a contract is a type and never a
requires bound (SPR-GENERIC-CONSTRAINT; take the contract as the parameter
type), and a contract has no default methods (shared behavior is a module
function that takes the contract). There is no downcast and no type test on
class or contract values, conform is written in the module that declares
the class, and conformance is always declared. The decision guide: a closed
set of types is a variant; an open set is a contract.

One-line classes

class Pair(first: Int, second: Int) declares a class of immutable fields and
builds the same class as the block form: named construction, the same printed
text and identity ==. var fields, defaults, methods and empty parentheses
are not accepted in this form and get a targeted SPR-SYNTAX-ERROR pointing at
the block form. This is the answer to tuples: featureGuidance.tuples names
it.

Function references

A named function, a module function or a method used without a call is a
function value with the type of the lambda that forwards to it, throws clause
included:

func shout(word: String) -> String:
    return word.toUpperCase()

print(words.map(shout))
print(lists.sum(numbers.map(parse)))
numbers.forEach(counter.add)
words.forEach(print)

A method reference evaluates its receiver once, when the value is created. A
generic function writes its type arguments (identity[Int],
SPR-TYPE-GENERIC-ARGS-REQUIRED otherwise). print is a value only where a
fn(T) -> Unit is expected, such as a forEach argument; let p = print is
SPR-TYPE-NOT-CALLABLE with the lambda to write. Java and built-in methods
(list.size, text.trim) stay calls: write the lambda. Function values are not
compared with == or != (SPR-TYPE-OPERAND).

Error classes

A class with a message: String field that conforms to Error is an error
type: it is thrown, declared in throws, caught by its own name or as Error,
and shows its message wherever it becomes text.

class ParseError:
    let message: String
    let line: Int

conform ParseError to Error(message)

func parse(text: String) -> Int throws ParseError:
    ...

A catch of such a class after a catch of Error is unreachable; a lambda that
throws one has throws Error, because only Error crosses a function value.

Read-only views of mutable collections

A MutableList[T] is accepted where a List[T] is expected, and a
MutableMap[K, V] where a Map[K, V] is, as the same collection seen
read-only: no copy, and later changes through the mutable name are visible
through the read-only one. The reverse is never accepted, and element types
stay invariant (MutableList[MutableList[Int]] is not a List[List[Int]]).

Small additions

  • compareTo on Int, Int32, Float, Float32, Decimal and BigInt:
    the argument has the receiver's type and the result is an Int32, the int a
    Java Comparator returns.
  • String.lastIndexOf.
  • An f-string (f"total {n}") gets a hint that spells the + join; format,
    lines and the other common Python, Java and JavaScript String method names
    get a hint that names the Sprig method.
  • A list or map literal compared with ==/!= takes the other operand's
    type (#166): xs == [] needs no annotation, and xs == [1, 2] with
    xs: List[Int32] compares Int32 elements. Both were type errors. When
    both operands are literals, the one that has a type of its own gives it to
    the other, on either side: [[]] == [[1]] compiles like [[1]] == [[]]
    (#212).
  • Two cases of one variant can be compared (#166):
    Shape.Circle(radius=1.0) == Shape.Square(side=1.0) is false; it was
    SPR-TYPE-MISMATCH.

Java interop

  • Extending a Java class. conform C to J(field, ...) as NAME makes the
    generated class extend a public, non-final, non-generic Java class: the named
    fields select the superclass constructor and are passed as super(...),
    methods with the same shape override, and NAME.m(...) calls the inherited
    implementation. The Fabric starter's item is written this way
    (conform Wand to Item(properties) as parent).
  • Nullability annotations are read. A result or field annotated
    NotNull/NonNull/Nonnull is a plain T; a parameter annotated
    Nullable/CheckForNull accepts a T?; NullMarked, NonNullApi and
    MethodsReturnNonnullByDefault make unannotated results non-null, and
    NullUnmarked cancels the default; both are read on a method as well as on
    a class, a package or a module, and the innermost one wins (a
    @NullUnmarked method inside a @NullMarked class keeps its T?, so its
    null is checked instead of failing at run time). Annotations kept only in class files
    (org.jetbrains.annotations, Android, Eclipse) are read from the class file.
    Parameters are never made nullable by a default.
  • Java method type parameters are inferred when the arguments fix every one
    of them exactly: Collections.sort(names) takes T from an
    ArrayList[String] argument and JList.of(1, 2, 3) from its elements.
    Through a lambda, null, a Sprig collection or the result they are not, and
    Collectors.toList() still writes its argument.
  • A functional-interface formal bound by a type argument takes a fn. A
    parameter written as a type variable (AtomicReference[V].set(V), Fabric's
    Event[T].register(T)) names the interface its type argument bound it to, so
    let slot = AtomicReference[Runnable](); slot.set(fn() => print("ran"))
    compiles and adapts like any other Java callback; the erased Object formal
    hid it before. The same fix adapts the callback at code generation.
  • --classpath-file FILE on check, build, run, api, wrap,
    doctor, test and lsp; the Gradle plugin uses it, so a Loom classpath of
    several hundred JARs no longer exceeds the Windows command-line limit.
  • Fn0..Fn3 slots whose type arguments are a Java method's own type
    parameters are typed once those parameters are bound (start[Int](work)).

Standard library and libraries

@std/concurrent.spr: structured scopes over virtual threads

Concurrency is a bundled module, not a separate library. Every task runs inside
a scope, on a JDK 21 virtual thread:

import "@std/concurrent.spr" as concurrent

func fetch_all(urls: List[String]) -> List[String] throws Error:
    return concurrent.scope(fn(tasks: concurrent.Scope) =>
        concurrent.await_all(urls.map(fn(url: String) => concurrent.spawn(tasks, fn() => fetch(url)))))

scope returns when its body and every task spawned in it have finished; the
first failure cancels the sibling tasks and is rethrown from scope, so no task
outlives the function that started it. spawn_on(scope, pool, work) runs a task
on a fixed pool(threads) of platform threads for CPU-bound work;
parallel_map, map_in, await_all, channel[T](capacity), counter,
lock/locked, latch and sleep complete the module. There is no unscoped
spawn, no async/await syntax (featureGuidance.asyncAwait explains why),
no then on a task and no scope deadline. examples/parallel_words uses it.

More of @std, and faster JSON

  • regex.find_groups(pattern, text) returns the capture groups 1..n of the
    first match, or null when nothing matches; a group that took no part is
    null, so the type is List[String?]?. regex.find_all_groups returns the
    groups of every match, in order.
  • lists.max/lists.min (Comparable items) and lists.max_by/lists.min_by
    (Comparable key) return null for an empty list. They use the order of
    sorted, so a Float NaN is the largest value; of equal items or keys the
    first wins.
  • text.escape_html replaces &, <, >, " and ' with entities.
  • json.parse no longer copies the rest of the document for every token
    (#186), and parsing a string, quote and stringify build their text in one
    buffer instead of concatenating. An array of 100,000 true/null values
    parses in about 4 ms instead of 430 ms (5 ms instead of 6.5 s when the text
    holds a character outside Latin-1); stringify of 100,000 numbers takes
    23 ms instead of 2.3 s.
  • sprig help modules, std/json.spr and the sprig-web README point from
    @std/json to @std/json_codec for reading fields with paths and kind
    checks, and sprig help testing, the testing guide and tutorial §12.3 show
    how to keep src/main.spr to argument handling so tests import the work
    without running the program (#150).
  • Every declaration of @std and the bundled packages has a doc that
    sprig api shows; helpers that callers should not use say so with a doc
    that starts with Internal: (#210).

Web, HTTP, SQLite

sprig-web gains app.run_on(host, port) for binding an address other than
127.0.0.1, and PUT/PATCH routes; CORS covers both methods.

sprig-sqlite's Migrations.apply() checks the whole directory before it runs
anything: names, sequence numbers, order against the ledger, and that every
pending file can be read. A problem is an Error and no migration runs, and
the order error names both files (#208). A migration file may end with a
comment.

Package registry

Packages are published by pull request to registry/ in this repository. The
index is strict: names are [a-z][a-z0-9-]* (std and sprig reserved),
versions are SemVer, every release pins a tag and its commit, an entry carries
license and owners, a release is withdrawn with yanked/reason rather
than deleted, and a renamed package points at its successor with moved_to.
sprig publish writes the entry and names the pull request step; a version
that is already listed is an error. The Registry index workflow validates
every change to registry/ (schema, immutability of published releases, that
the tag's commit holds the manifest, and that the author is an owner), and
CODEOWNERS routes the review. sprig search and sprig add NAME read the index, pinned under ~/.sprig/registry for --offline.
There is still no hosted registry, no authentication and no upload.

Tooling

  • sprig build --bundle writes a directory that runs the program on a
    machine without a JDK: a POSIX sh and a Windows launcher, lib/ with the
    program's JAR, the Sprig runtime and every locked JAR, and runtime/, a
    jlink image of the modules jdeps finds in those JARs, with the JDK's legal
    notices kept. --archive adds a ZIP. The launchers keep a class-data-sharing
    archive of the program's classes under the user's cache directory, so a warm
    start is the JVM's own start plus the program. The image runs only on the
    platform that built it; SPR-BUNDLE-TOOLS, SPR-BUNDLE-JDEPS and
    SPR-BUNDLE-LAYOUT name what a bundle needs. See docs/projects/bundle.md.
  • The test gate runs in parallel. scripts/test.py --jobs N (default CPU
    count capped at 8) and scripts/test-affected.py, which runs the gates a
    change touches, from tests/test-map.json.
  • Quick fixes in sprig lsp. The suggested edits that sprig check --json
    carries (else if to elif, positional constructor arguments to named ones,
    a missing @std import, an undeclared throws, Int.toFloat() to
    toFloatExact()) are offered as quickfix code actions; the VS Code
    extension shows them in the lightbulb.
  • Editor assistance from the language server. textDocument/signatureHelp
    describes the call being typed — Sprig functions and methods, named
    constructors, callable values and Java overloads, including incomplete calls
    and nested or multiline arguments — and textDocument/semanticTokens/full
    classifies parameters, locals, fields, types, functions, methods and Java
    members. Find References now warns when its 200-file analysis limit leaves
    matching project files out instead of stopping silently. The VS Code
    extension 0.3.1 ships with this release and registers both providers.
  • Formatter. A continued line starting with a binary + or - keeps its
    spaces (sprig fmt wrote +"y" for \n + "y").
  • Output is UTF-8 on Linux and macOS whatever the locale says; a process
    without a locale, as in a container, printed ? for non-ASCII text.
  • run and test cache javac output under ~/.sprig/cache/javac, keyed by
    the program's sources and classpath, so a program that ran before starts
    without a second javac run; --no-cache compiles anyway.
  • Diagnostics. An error in an unannotated top-level initializer no longer
    stops the module's check, so the other errors are reported in the same round,
    and identical duplicate diagnostics are dropped. Chains of more than 64
    elif conditions in an if expression are emitted as a flat switch block
    (javac overflowed near 1,500 nested conditionals). New stable codes:
    SPR-CLASS-ABSTRACT, SPR-CONFORM-PARENT, SPR-DEP-REGISTRY and the three
    SPR-BUNDLE-* codes; SPR-CONFORM-MEMBER, SPR-CONFORM-TARGET and
    SPR-TYPE-GENERIC-ARGS-REQUIRED cover more cases than before. The stable set
    drops SPR-NAME-NOT-A-TYPE and SPR-NAME-MODULE, which no check ever
    emitted: let t: v = 2 is SPR-NAME-UNRESOLVED and a duplicate import alias
    is SPR-NAME-DUPLICATE.
  • A type argument that a generic declaration rejects is reported at your
    use (#212).
    sets.of([0.5]), sets.Set[Float32](), lists.distinct([0.5])
    and a sets.Set[Float] annotation reported SPR-NUM-CONVERSION inside
    std/sets.spr or std/lists.spr, once for all such uses. The error is now
    at each call, construction or annotation that gives the type argument, with
    the same code and message, and the hint names the place inside
    (Rejected inside 'of' with T = Float, at @std/sets.spr:7:33).
  • A runtime failure inside @std points at your line. sprig run and
    sprig test reported an uncaught failure in a standard-library function,
    such as an overflowing concurrent.Counter.add or lists.sum, at a line of
    std/.... They now report the innermost line in the program's own modules,
    imported modules and dependency packages included; --stacktrace still
    prints the whole stack. A program run outside sprig run prints one
    sprig-runtime-frame: line per distinct frame of generated code after its
    sprig-runtime-failure: line.
  • Help and capabilities describe each addition: help classes (contracts,
    one-line form), help conform, help functions (references), help concurrency, help dependencies (registry rules); supportedSyntax,
    unsupportedSyntax and featureGuidance (interfaces, tuples,
    namedFunctionReferences, asyncAwait, packageRegistry) follow the
    compiler.
  • Website. The tutorial is a 24-chapter beginner book in Chinese and
    English (/tutorial/, /en/tutorial/): every complete program is a checked
    snippet with its real output, each chapter ends with exercises and folded
    answers, and the old chapter URLs redirect to their new homes. The book
    requires the 0.8 compiler from main until this release is installed.
  • Documentation audit. docs/, the website guides, sprig help,
    sprig explain and the READMEs were read claim by claim against the
    compiler; the statements that had drifted were corrected, never-emitted
    diagnostic codes were removed, and sprig explain of an unknown code now
    exits 2.

Upgrade from v0.7.1-beta.1

Run sprig upgrade, then sprig resolve once in each project: a lock records
the compiler that wrote it.

Incompatible: JDK 21 or newer

The compiler, the generated code, the Gradle plugin and the installer require
JDK 21. sprig doctor and install-sprig.sh report an older JDK with the
version it found. v0.7.1-beta.1 accepted JDK 17 and remains available for
projects that cannot move yet. CI runs JDK 21 and 26; the Windows preview runs
the same.

Incompatible: one spelling per numeric conversion

Four conversions had two equivalent spellings. Each now has one:

Removed Write instead
n.toFloat() on an Int n.toFloatExact() (same behaviour), or n.toFloatLossy() to round
x.toInt() on a Float x.toIntExact() (same behaviour), or x.toIntTrunc() to drop the fraction
Decimal.fromInt(n) n.toDecimal()
Int.parse(text) text.toInt()

A conversion between number types without a suffix never fails
(Int32.toInt(), Int32.toFloat(), Float32.toFloat(), toDecimal()). One
that can lose information says what happens: Exact fails, Lossy rounds
and Trunc drops the fraction. The checker reports each removed spelling as
SPR-NAME-UNRESOLVED with its replacement. The language server's quick fix
rewrites toFloat() and toInt().

Soundness fixes (code that compiled before is rejected)

  • Narrowing in elif and else (#125). A var initialized from a
    narrowed name in an else after an elif takes the narrowed type, where it
    took the declared type before; declare it (var node: Node? = head). A
    lambda capturing such a name gets the narrower function type; declare the
    function type (let again: fn() -> String? = fn() => name). Both already
    happened in a then branch. The diagnostic is SPR-TYPE-ASSIGN or
    SPR-TYPE-MISMATCH at the later assignment.
  • Library calls that can throw say so (#209). sqlite Rows.is_null,
    web Request.json() and web.json_response declare throws Error;
    json_response throws an Error ("cannot write the response as JSON: ...")
    instead of IllegalStateException. Route handlers are
    fn(Request) -> Response throws Error, so a handler may let these errors
    escape: the server still answers 400 for a malformed JSON body and 500
    otherwise. A named function that calls them outside try must now declare
    throws Error; handlers without throws compile as before.
  • A Float map key through generic code is rejected (#212). The map key
    rule was checked only where Map[Float, V] is written. A generic function or
    class whose body keyed a map literal by its type parameter
    (let seen = {value: true}), or passed it on to another generic such as
    sets.of(values), compiled with T = Float, and value in seen then found
    a NaN. Each use of generic code is now checked with its type arguments
    against what its body infers as well as what it writes, all the way down, so
    such a call is SPR-NUM-CONVERSION like a written Map[Float, Bool].
    Inferred inner uses also follow the nullability rule: a nullable type
    argument that reaches a declaration writing T? is
    SPR-TYPE-GENERIC-NULLABLE at the outer call, as it already was when the
    inner use wrote [T].
  • A contract never conforms to a Java interface or class. conform Runner to Runnable with Runner a contract class checked clean and did nothing:
    the generated interface did not extend Runnable, so a class conforming to
    Runner was still rejected where a Runnable was expected. It is now
    SPR-CONFORM-SOURCE, as conforming a contract to another contract already
    was; conform each implementing class to the Java interface as well.

Behavior fixes (code that still compiles does something else)

  • Function values are rejected for Equatable and as Map keys (#222).
    requires T: Equatable accepted an instantiation with a function type, so a
    generic function could compare two function values although f == g is
    SPR-TYPE-OPERAND where written; the capability is now checked at the call
    (SPR-GENERIC-CONSTRAINT, like Comparable), through a nullable type
    argument too (U? is Equatable exactly where U is). A function-valued Map
    key, written in the annotation, inferred from a literal or reached through
    generic code such as sets.of, is SPR-TYPE-OPERAND too: a Map key needs
    equality.
  • A qualified name as an index is resolved like any expression (#222). A
    bracket payload that parsed as a name (by_day[Day.Mon]) and whose leftmost
    name was a type or an imported module never got its symbols: the checker
    typed it as an error without reporting one, and code generation stopped with
    SPR-JVM-INTERNAL. It now resolves as a value, a type or a module, so the
    enum case indexes the Map and a genuine mistake is a normal diagnostic
    (by_name[math.PI] is SPR-NAME-UNRESOLVED, by_int[Box] is
    SPR-NAME-NOT-A-VALUE).
  • A match expression does not unbox a nullable branch beside a primitive
    branch (#219).
    A branch yielding an Int?/Int32?/Bool?/Float?/
    Float32? name, field or call beside a primitive literal or local was
    compiled into a Java switch expression whose inferred type was the primitive,
    so selecting the nullable branch called longValue() (and the like) on a
    null and the program stopped with SPR-RUNTIME-EXCEPTION. Every branch now
    yields the Java type of the match result, as if-expression branches already
    did; null stays null and the other branch's value is unchanged.
  • A function value passed to a Java functional interface is evaluated
    once.
    words.forEach(printer()) called printer() for every element;
    it is now called once, before the Java call, as every other argument is. Only
    a lambda written in place is created per call as before. Find the code
    affected by searching for Java callback arguments that are calls rather than
    lambdas or names.
  • A number literal takes the other operand's width on either side (#212).
    Only a literal on the right did: 0.1 == f32 was false while
    f32 == 0.1 was true, 1.0 + f32 printed 1.1000000014901161 while
    f32 + 1.0 printed 1.1, a negated literal (f32 + -0.5) was Float, and
    1 + small with small: Int32 was Int arithmetic that did not overflow
    where small + 1 did. Now the order of the operands never changes a result;
    3000000000 + small is SPR-NUM-RANGE like small + 3000000000, and
    0.5 in xs with xs: List[Float32] compiles like xs.contains(0.5).
  • List membership uses == for floats (#165). x in xs, contains,
    indexOf and MutableList.remove find the first element == the value: a
    Float or Float32 NaN is never found, and -0.0 finds 0.0 and the reverse.
    v0.7.1 used Double.equals, which found a NaN and told the zeros apart. To
    find a NaN, test x.isNaN().
  • A scope reports the failure, not the cancellation it caused (#205). When
    a body awaited a task that a sibling's failure had cancelled (as
    await_all, parallel_map and map_in do), scope() threw "task was
    cancelled"; it now throws that first failure.
  • @std/json_codec reader failures name the path (#206). A field of a
    hand-built object with a duplicate key throws $: duplicate object key: a;
    a number whose exponent is out of Decimal's range throws
    $.n: expected decimal in range, found number 1e9999999999 where the
    program used to stop with SPR-RUNTIME-EXCEPTION; *_number_text readers
    reject a hand-built number whose text is not a JSON number;
    reject_unknown_fields reports a duplicate key in the same words, where it
    said $: duplicate field 'a'.
  • SQL text holds one statement (#208). Database.execute,
    Database.query and each batch Statement throw an Error for a second
    statement and change nothing; before, only the first ran, silently. A
    trailing semicolon or comment is still fine.
  • A SQLite trigger body ends where SQLite ends it. sprig-sqlite ended a
    CREATE TRIGGER body at the first END that no CASE had opened, so a
    column named end in the body (UPDATE events SET end = 5;) cut the trigger
    short: execute counted several statements and a migration failed with
    "incomplete input". The body now ends as SQLite's sqlite3_complete ends it,
    at an END that is the first token after one of its semicolons.
  • concurrent.Counter fails on overflow (#209). add, increment and
    decrement fail past the Int range with the same numeric error as Int
    arithmetic and leave the value unchanged; they wrapped around silently.
  • files.atomic_write_utf8 keeps permissions and follows links (#209). It
    keeps the permissions of the file it replaces, a new file gets what
    write_utf8 gives it (the process umask) instead of rw-------, and a
    symbolic link at the path is written through, as with write_utf8.
  • files.atomic_write_utf8 refuses a file it may not write. A rename
    needs only the directory's permission, so it replaced a read-only file that
    write_utf8 refuses. It now fails the same way, cannot write PATH: access denied, and writes nothing, also through a symbolic link.
  • Request.query cannot fail. sprig-web decodes the query once, before
    routing, into the new Request.query_values (each name's first decoded
    value), and query(name) looks the name up there; a malformed query is still
    a 400. A Request built by hand, as in a handler test, reads only the
    query_values it is given (empty by default). Before, query decoded
    raw's query on every call and could throw BadRequest although it does not
    declare throws.
  • Pool.shutdown_now() cancels running tasks (#209). A running pooled
    task used to be interrupted without being cancelled and failed its scope
    with "sleep was interrupted"; it now counts as cancelled, which is not a
    scope failure.
  • files.file_name of a root is an Error (#210). files.file_name("/")
    stopped the program with a JVM NullPointerException.
  • A terminated sprig run waits for its program (#199). It used to exit
    while the program was still shutting down, so a server's port could still be
    open; it now waits up to 5 s, then stops the program and its children.
  • A Git dependency is checked out as committed. The cache compares every
    checked-out file with the commit, byte for byte, but let the dependency's own
    .gitattributes convert files during checkout. The Sprig repository asks for
    CRLF in .bat files, so a fresh cache could not resolve any dependency from
    it: --git dependencies on the first-party libraries, and sprig search and
    sprig add NAME with the default registry, failed with SPR-DEP-GIT Git checkout verification failed. Line-ending conversion, keyword expansion,
    filters and re-encoding are now off for dependency checkouts.

Diagnostics that replace javac errors

  • == or != on two function values is SPR-TYPE-OPERAND at check time; it
    reached javac before. Compare the result of calling them, or compare the
    values they close over.

New meaning for syntax that was rejected before

  • A method header without a body inside a class makes the class a contract.
    v0.7.1 reported a syntax error, so no existing program changes meaning. A
    function without a body outside a class stays SPR-SYNTAX-ERROR.
  • class Name(fields) on one line is a class; it was a syntax error.
  • An if after =, return, throw or => is an if expression; it was a
    syntax error.

Libraries

  • libraries/sprig-concurrent, which existed only on main between two
    releases and was never published, is removed in favor of @std/concurrent.spr.
    Its spawn(work) becomes spawn(scope, work) inside scope(...);
    await() on a task is unchanged; pool(threads) plus spawn_on keep their
    names; cancel() on a task is cancel_all() on the scope.
  • sprig publish refuses a version that the registry already lists; publish
    the next version or --yank the listed one with a reason.

Known limits

Contracts are non-generic and have no default bodies; function references take
at most three parameters and never reference Java or built-in methods; type
arguments are never inferred from the expected type; a scope has no deadline
and a task no then; the registry is the repository index without hosting or
authentication; the compiler is not self-hosted. See
known-limitations.md.