Tya v0.44 Specification

This document is the specification for Tya v0.44 after v0.43 concurrency known-gap close-out.

Theme

Tya v0.44 is about a class-oriented namespace and entry-file model.

Through v0.43 Tya carried two coexisting library shapes: snake_case module name files containing free functions, and PascalCase class declarations placed inside modules. v0.44 picks one shape — class — and reorganizes the surrounding namespace machinery so that every reusable unit is a class, every file’s role is decidable from its name alone, and every program has exactly one source representation in the spirit of Canonical Syntax.

Goals

Non-Goals

v0.44 does not include:

File Kinds

A .tya file’s role is determined by the first character of its filename:

Filename starts with Kind Role
Uppercase letter Class file Library; importable
Lowercase letter Script file Entry; not importable

Filenames starting with _, digits, or other characters are language errors.

Class file

A class file is a PascalCase .tya file containing exactly one top-level public class whose name matches the filename without .tya.

Request.tya          ->  class Request
HttpClient.tya       ->  class HttpClient

A class file may additionally declare any number of private classes alongside the public one. A class is the file’s public class only if its name matches the filename without .tya. Every other class in the file is private and visible only within that file. Private classes follow the same PascalCase naming rule.

# Request.tya
class Header
  init = name, value ->
    @name = name
    @value = value

class Request
  init = url ->
    @url = url
    @headers = []

Here Request matches the filename and is the file’s public class. Header does not match the filename and is private; another file may declare its own Header without conflict.

A class file must not contain top-level statements other than import, class declarations, and interface declarations.

Script file

A script file is a lowercase .tya file. Its body is top-level statements and bindings, optionally preceded by import lines.

# hello.tya
print("hello")
# client.tya
import net/http

request = http.Request()
print(request.get("http://example.com"))

A script file may declare private classes; they are visible only within the file. A script file must not declare a public class with a name matching the filename, because a script file’s public class is generated implicitly (see Entry execution).

A script file is not importable. import paths never resolve to a script file.

Namespace and Packages

A package is a directory. The directory name is the package’s identifier. Package directory names must match the variables-and-functions naming rule (snake_case). All .tya files in a directory share that package’s namespace.

Within a single package directory, sibling class files refer to each other’s public classes without any prefix.

# net/http/Response.tya
class Response
  ...

# net/http/Request.tya
class Request
  init = url ->
    @url = url

  send = ->
    Response()                # sibling, no prefix

Packages may nest arbitrarily deep through directory nesting:

stdlib/
  net/
    http/
      Request.tya
      Response.tya
    tcp/
      Socket.tya
  os/
    Os.tya

Import

import path/to/package loads the package directory at path/to/package and binds the last path segment as the prefix used at call sites.

import net/http

req = http.Request()
res = req.send()

Resolution

import path resolves path against the following roots, in order:

  1. The directory containing the entry script file.
  2. Each entry of TYA_PATH, in order.
  3. The bundled stdlib/ directory.

The first directory found that matches path exactly is the resolved package.

Restrictions

Same-Package Reference

A .tya file does not need to import its own package. All public classes in the same directory are in scope without prefix.

Cross-Package Reference

Public classes in another package are only reachable through import and the package prefix. The full reference form is <last-segment>.<ClassName>, even when the package contains a single class whose name matches the directory:

stdlib/math/Math.tya       contains class Math

import math
Math.sin(0.5)              # ERROR: Math is not in scope
math.Math.sin(0.5)         # OK

The math.Math repetition is intentional and consistent with Java’s java.lang.Math.sin() style.

Same-Segment Package Collision

Two directory packages whose paths share the same terminal segment synthesize the same module name and would clobber each other in the merged source. The runner detects this at synthesis time and rejects the second load with a package name conflict diagnostic that names both originating directories. The check covers both unaliased and aliased imports:

# Both rejected at synthesis time:
import a/net           # would synthesize module net
import b/net           # would also synthesize module net → conflict

import a/net as a_net  # synthesis still uses terminal segment net
import b/net as b_net  # → conflict

Resolve by giving the directories distinct names (import a/net + import b/socket, etc.). Same-name packages behind different aliases are tracked under M5 / M8 follow-up; the underlying limitation is that the synthesized module name is derived from the terminal segment alone.

Entry Execution

tya run path/to/file.tya accepts only script files (lowercase filename). Running a class file is a runner error.

The runner desugars the script file into a class file with an unnamed class that has a main method whose body is the script’s top-level statements:

# hello.tya  (what the user writes)
import os

print("hello")
for arg in os.Os.args()
  print(arg)

is internally equivalent to:

# implicit form  (not user-writable)
import os

class _Anonymous
  main = ->
    print("hello")
    for arg in os.Os.args()
      print(arg)

The implicit class is unnamed; it has no source-level identifier and cannot be referred to from any file. Bindings introduced at script top-level (e.g. request = http.Request()) become locals of the implicit main.

This desugaring is internal. The strict form is not user-writable: PascalCase class files with a main method exist as ordinary library classes and are not entry points.

Class Files Are Library-Only

A class file’s main method, if defined, has no special meaning. It is an ordinary instance method. tya run Hello.tya is a runner error regardless of whether Hello.tya defines main.

Rationale: every Tya program has exactly one source representation per Canonical Syntax. Allowing both tya run Hello.tya (with class Hello + main) and tya run hello.tya (compact) to launch a program would create two ways to write the same thing.

Public and Private Classes

Every .tya file contains exactly one public class:

Any additional class declared in the same file is private: it is visible only to code inside that file. Private classes have no externally visible name and cannot be referenced from another file under any mechanism, including reflection.

Private classes follow the same naming rules as public classes (PascalCase). They may use extends, implements, abstract, final, and override exactly like public classes.

Class Member Conventions (Unchanged)

The class member surface from v0.10–v0.13 is preserved without change:

A class with no init and only @@method members is a Java-style utility class. This is a documented convention; the language does not add a keyword for it.

CLI Arguments

CLI arguments are not delivered as a main parameter. They are read through the standard library:

import os

for arg in os.Os.args()
  print(arg)

os.Os.args() returns an array of strings (empty when no arguments). main’s signature is fixed at no parameters.

Removed Constructs

Naming Rules (v0.44)

script file (entry):  lowercase ASCII (e.g. hello.tya, client.tya)
class file:           PascalCase (e.g. Request.tya), filename = class name
package directory:    snake_case (e.g. net, http, file_system)
variables/functions:  snake_case
private binding:      _snake_case
classes:              PascalCase
class methods:        @@snake_case
instance fields:      @snake_case
dictionary keys:      snake_case
constants:            SCREAMING_SNAKE_CASE

Errors

The new rules introduce the following diagnostics. Code blocks are reserved per stage; individual codes are finalized when each STEP lands.

Reserved ranges:

Range Stage Purpose
E0200E0219 parser module keyword removed (M9)
E0400E0429 checker class file structure (M2, M5)
E0850E0879 runner import resolution and entry kind (M3, M4)

Diagnostics in scope (wired codes are emitted as [TYA-EXXXX] prefixes on the runtime error message; “TBD” entries are reserved but not yet wired):

Code Wired? Stage Condition
E0400 yes checker Class file does not define the matching public class.
E0402 yes checker Class file contains a non-import / non-class / non-interface top-level statement.
E0403 yes checker Class file’s imports do not precede class / interface declarations.
E0404 yes checker Class file’s filename is not PascalCase.
E0405 yes checker Duplicate public class declaration in a class file.
E0406 TBD checker Cross-file reference to a private class (M5 enforcement).
E0850 yes runner tya run invoked on a class file (only script files are runnable).
E0851 yes runner import path is invalid (absolute, dotted, empty segment, PascalCase).
E0852 yes runner Package directory contains a script file (lowercase leaf).
E0853 yes runner Package directory contains no class files.
E0854 yes runner Package directory name is not a valid snake_case identifier.
E0855 yes runner Two directory packages would synthesize the same module name.
E0200 TBD parser module keyword used (removed in M9).

Codes in E04xx and E08xx are additive within the checker and runner ranges already reserved by docs/v0.29/CODES.md. The parser block E0200E0219 is reserved out of the parser range E0100E0299 allocated for the Toolchain “Migrate remaining stages to the diagnostics pipeline” Epic; the module-removal code lands when the parser has been migrated.

Examples

Single-file script

hello.tya
# hello.tya
print("hello")
$ tya run hello.tya
hello

Two-file program

Greeter.tya
main.tya
# Greeter.tya
class Greeter
  init = name ->
    @name = name

  greet = ->
    "Hello, {@name}"
# main.tya
greeter = Greeter("komagata")
print(greeter.greet())
$ tya run main.tya
Hello, komagata

Imported package with multiple classes

lib/
  net/
    http/
      Request.tya
      Response.tya
client.tya
# lib/net/http/Request.tya
class Request
  init = url ->
    @url = url

  send = ->
    Response()        # same package, no prefix
# lib/net/http/Response.tya
class Response
  status = ->
    200
# client.tya
import net/http

request = http.Request("http://example.com")
response = request.send()
print(response.status())

Utility class

stdlib/
  math/
    Math.tya
# stdlib/math/Math.tya
class Math
  pi = 3.14159265358979

  @@sin = x ->
    ...

  @@cos = x ->
    ...
# user code
import math

print(math.Math.sin(0.5))
print(math.Math.pi)

Private class inside a class file

# Server.tya
class Connection
  init = socket ->
    @socket = socket

  close = ->
    ...

class Server
  init = port ->
    @port = port
    @connections = []

  accept = ->
    conn = Connection(socket)    # Connection is file-private
    push(@connections, conn)

Server matches the filename and is public. Connection does not match and is private; nothing outside Server.tya can reference it.

Self-Host Invariant Constraint (informative)

The Tya-written self-host compiler at selfhost/v01/compiler.tya is a v0.1 surface compiler that resolves import X by reading X.tya as a single-file module. It does not understand v0.44 directory-as-package layout.

tests/testdata/v01_selfhost/*.txtar invokes that v0.1 compiler on test inputs that may import string and array (and any other stdlib module the v0.1 compiler exercise depends on). For the v0.44 migration to preserve the self-host fixed-point gate TestSelfhostV01Scripts, every stdlib package referenced by these tests must remain in the legacy module name + .tya file shape until either:

In practice, M6 keeps the following stdlib packages in the legacy module-file shape and defers their class migration to M8:

stdlib/dict.tya is not currently exercised by v0.1 selfhost tests but is grouped with string/array for consistency: the three core collection-style modules migrate together when M8 lands.

Other stdlib packages (runtime, time, channel, sync, task) are independently held back in M6 because their callers in examples/ and tests/testdata/v4{1,2,3}/ need updating in the same change, and that touches a broader cross-section of the working tree than this Epic should sweep at once. They migrate in follow-up STEPs.

Migration Sketch (informative)

The implementation order, captured for cross-reference with ROADMAP.md:

  1. Parser/checker accepts the new model alongside the existing module keyword. Both shapes coexist temporarily.
  2. Resolver gains directory-as-package support. Existing module imports keep working.
  3. Compact entry-file desugaring becomes the runner’s default for lowercase files; existing top-level execution semantics are preserved.
  4. Private-class semantics land.
  5. stdlib/ is migrated package by package from module + functions to class-file form. Each package landing keeps tests green.
  6. examples/ is migrated.
  7. selfhost/v01/compiler.tya is migrated, preserving the self-host fixed point at every STEP.
  8. The module keyword is removed; remaining module files are deleted or moved.
  9. docs/SPEC.md, docs/NAMING.md, docs/STDLIB.md, and docs/CANONICAL_SYNTAX.md are updated to reflect v0.44.

The detailed STEP breakdown lives in ROADMAP.md.