mt-lang 0.4.2 → 0.4.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 726a973cae08da6d7927931a6fa51adc8106fd483709dede501b7e551e1d9103
4
- data.tar.gz: 666501d1a75719851869d1467d7498e84c2b4fd18da6d52a41912343d2f52f43
3
+ metadata.gz: 60f503669467624ebec5461f2f5fb5c3dae9216718b4c445f182323b90e9f505
4
+ data.tar.gz: d5ace59e3fdadbe3a7ea75f740d0d1d2953088d1799b42e9e6899c900e50595a
5
5
  SHA512:
6
- metadata.gz: 618b5705f9b727ce7d07b6ccf6576aa92d0380d7f4609393790fb8c64df1a3c7bd409151eabc800d95ca01d6636c8e26d43840758db90502ccf2757ad6800190
7
- data.tar.gz: e9a6840730d61fee0b57c3339779169a31e91d84626230cc353c1e9b31a1e748b58a0b5c205d904bbb32ba0951bfd4874df3374c378a62c8a6e8277bc1fccffe
6
+ metadata.gz: e74f76c097577a41ccc21462f7ede0d2c2a77f84f883e0ca3026b6b3f966e4b87ba3cfc91eca5dc16165edbc50a6f2bcff6be7ef6be751a1ba95f86b472b6e00
7
+ data.tar.gz: 652e7de9f0afd01aeac1358af1c47a2a19bce244ec53838b5cfc65b103d44cafea61210d01ca766bf390d4b649268d50d6ac2c057ce40e09f64e25b24c4a3d76
data/README.md CHANGED
@@ -20,7 +20,7 @@ It is the preferred first entry point for getting to know the language.
20
20
  If this file conflicts with `docs/language-manual.md`, the manual wins.
21
21
  `docs/language-design.md` is for design direction and rationale, not the authoritative implementation spec.
22
22
 
23
- Package manifests, build workflow, and run workflow are documented separately in `docs/build-guide.md`.
23
+ Package manifests, build workflow, and run workflow are documented separately in `docs/build-guide.md`. The `@[test]` attribute, assertion intrinsics, and `mtc test` runner are documented in `docs/testing.md`.
24
24
 
25
25
  ## 1. File Kinds And Layout
26
26
 
@@ -860,6 +860,10 @@ Generics:
860
860
  Special recognized callables:
861
861
 
862
862
  - `fatal(message)`
863
+ - `assert(condition, message?)` — runtime check; aborts when the `bool` condition is false
864
+ - `expect(condition, message?)` — same as `assert`, always-on test assertion
865
+ - `expect_eq(actual, expected, message?)` — compares with the language `==` operator (primitives, `str`, structs, variants, arrays) and aborts when the values differ
866
+ - `expect_ne(actual, expected, message?)` — compares with `!=` and aborts when the values are equal
863
867
  - `ref_of(x)`
864
868
  - `const_ptr_of(x)`
865
869
  - `read(r)`
@@ -877,6 +881,8 @@ Special recognized callables:
877
881
  - `get(coll, index)` — recoverable array/span indexing returning `ptr[T]?`; null on out‑of‑bounds instead of aborting
878
882
  - `adapt[I](value)` — constructs a `dyn[I]` runtime interface value; verifies `value`'s type implements interface `I` at compile time
879
883
 
884
+ `assert`, `expect`, `expect_eq`, and `expect_ne` are recognized in bare call position (not reserved words) and take an optional `str`/`cstr` message. When omitted, the compiler synthesizes a message carrying the source location; the message is only evaluated on the failing path. A literal-false `assert(false, ...)` / `expect(false, ...)` is treated as terminating control flow, so it satisfies the `else:` block of a `let ... else:` guard.
885
+
880
886
  Reference and pointer notes:
881
887
 
882
888
  - `read(ref_value)` explicitly projects the referent value. Use `read(handle) = value` to write through a bare `ref[T]` value.
data/docs/index.html CHANGED
@@ -1879,6 +1879,10 @@ let sized = name[32]</code></pre>
1879
1879
  <table class="attr-table">
1880
1880
  <tr><th>Callable</th><th>Description</th></tr>
1881
1881
  <tr><td><code>fatal(message)</code></td><td>Terminate with message</td></tr>
1882
+ <tr><td><code>assert(condition, message?)</code></td><td>Runtime check; aborts when the <code>bool</code> condition is false</td></tr>
1883
+ <tr><td><code>expect(condition, message?)</code></td><td>Always-on test assertion (same as <code>assert</code>)</td></tr>
1884
+ <tr><td><code>expect_eq(actual, expected, message?)</code></td><td>Compares with the language <code>==</code> (primitives, <code>str</code>, structs, variants, arrays); aborts when the values differ</td></tr>
1885
+ <tr><td><code>expect_ne(actual, expected, message?)</code></td><td>Compares with <code>!=</code>; aborts when the values are equal</td></tr>
1882
1886
  <tr><td><code>ref_of(x)</code></td><td>Writable safe reference to an addressable lvalue</td></tr>
1883
1887
  <tr><td><code>const_ptr_of(x)</code></td><td>Read-only raw pointer to an addressable lvalue</td></tr>
1884
1888
  <tr><td><code>ptr_of(x)</code></td><td>Writable raw pointer from a mutable addressable lvalue</td></tr>
@@ -2660,6 +2664,7 @@ const ATOMS = new Set(['true','false','null'])
2660
2664
  const BUILTINS = new Set([
2661
2665
  'ref_of','ptr_of','const_ptr_of','read','reinterpret','zero','default',
2662
2666
  'hash','equal','order','size_of','align_of','offset_of','get','adapt','fatal',
2667
+ 'assert','expect','expect_eq','expect_ne',
2663
2668
  'field_of','callable_of','attribute_of','has_attribute','attribute_arg',
2664
2669
  'fields_of','members_of','attributes_of',
2665
2670
  ])
@@ -2,7 +2,7 @@
2
2
 
3
3
  This manual documents the Milk Tea language as implemented today in the lexer, parser, semantic checker, and compiler tests.
4
4
 
5
- Package manifests and build or run workflow are documented separately in `docs/build-guide.md`.
5
+ Package manifests and build or run workflow are documented separately in `docs/build-guide.md`. The `@[test]` attribute, assertion intrinsics, and `mtc test` runner are documented in `docs/testing.md`.
6
6
 
7
7
  ## 1. Source Files And Modules
8
8
 
@@ -1225,6 +1225,10 @@ The call site specializes with a literal: `int_with_bits[64]`.
1225
1225
  Special recognized callables:
1226
1226
 
1227
1227
  - `fatal(message)`
1228
+ - `assert(condition, message?)` — runtime check; aborts with `message` when the `bool` condition is false
1229
+ - `expect(condition, message?)` — same as `assert`, always-on test assertion
1230
+ - `expect_eq(actual, expected, message?)` — compares with the language `==` operator (works for primitives, `str`, structs, variants, arrays) and aborts when the values differ
1231
+ - `expect_ne(actual, expected, message?)` — compares with `!=` and aborts when the values are equal
1228
1232
  - `ref_of(x)`
1229
1233
  - `const_ptr_of(x)`
1230
1234
  - `read(r)`
@@ -1242,6 +1246,8 @@ Special recognized callables:
1242
1246
  - `get(coll, index)` — recoverable array/span indexing returning `ptr[T]?`; null on out‑of‑bounds instead of aborting
1243
1247
  - `adapt[I](value)` — constructs a `dyn[I]` runtime interface value; verifies `value`'s type implements `I` at compile time
1244
1248
 
1249
+ `assert`, `expect`, `expect_eq`, and `expect_ne` are special recognized callables, not reserved words: they are recognized in bare call position and may be shadowed by user declarations like any ordinary name. Their message argument (when given) must be `str` or `cstr`; when omitted, the compiler synthesizes a message carrying the source location. The message is only evaluated on the failing path. A literal-false `assert(false, ...)` or `expect(false, ...)` is treated as terminating control flow (like `static_assert(false, ...)`), so it may be used where the checker requires guaranteed exit, such as the `else:` block of a `let ... else:` guard.
1250
+
1245
1251
  `default[T]` requires an accessible zero-argument associated function `T.default()` that returns `T`.
1246
1252
 
1247
1253
  `hash[T](value)` lowers to `T.hash(value: const_ptr[T]) -> uint`, `equal[T](left, right)` lowers to `T.equal(left: const_ptr[T], right: const_ptr[T]) -> bool`, and `order[T](left, right)` lowers to `T.order(left: const_ptr[T], right: const_ptr[T]) -> int`. Each argument must already be a `ref[T]`, `ptr[T]`, or `const_ptr[T]`, or be a safe stored `T` lvalue that can be borrowed implicitly.
@@ -1547,6 +1553,7 @@ The auto-fix column corresponds to `mtc lint --fix`.
1547
1553
  | `shadow` | warning | — | Local binding shadows an outer binding with the same name |
1548
1554
  | `trailing-list-comma` | hint | yes | Trailing comma in call argument list is redundant |
1549
1555
  | `unreachable-code` | warning | — | Code after a guaranteed terminator cannot execute |
1556
+ | `redundant-unsafe` | hint | — | `unsafe` block contains no unsafe operations and can be removed |
1550
1557
  | `unused-import` | warning | yes | Import alias is never referenced |
1551
1558
  | `unused-local` | warning | — | Local binding is never referenced |
1552
1559
  | `unused-param` | warning | — | Parameter is never referenced |
data/docs/testing.md ADDED
@@ -0,0 +1,91 @@
1
+ # Milk Tea Testing Guide
2
+
3
+ This guide documents the in-language testing surface: the `assert`/`expect`/`expect_eq`/`expect_ne` intrinsics, the `@[test]` attribute, and the `mtc test` runner. The assertion intrinsics are part of the built-in callable surface (see `language-manual.md` §7); there is no standard-library testing module to import.
4
+
5
+ ## 1. Assertions
6
+
7
+ Four abort-based assertion intrinsics are available in every module, in both tests and ordinary code:
8
+
9
+ ```mt
10
+ assert(condition, message?) # runtime check
11
+ expect(condition, message?) # always-on test assertion
12
+ expect_eq(actual, expected, message?) # compares with the language `==`
13
+ expect_ne(actual, expected, message?) # compares with `!=`
14
+ ```
15
+
16
+ Rules:
17
+
18
+ - `condition` must be `bool`. `expect_eq`/`expect_ne` compare with the language `==`/`!=` operators, so they work for primitives, `str`, structs, variants, and arrays.
19
+ - `message` (when given) must be `str` or `cstr`; a `f"..."` format string flows straight in. When omitted, the compiler synthesizes a message carrying the source location.
20
+ - On failure the program aborts through the same path as `fatal`. The message is only evaluated on the failing path, so a `f"..."` message costs nothing while the assertion holds.
21
+ - `assert` defaults to always-on. `expect` is the test-flavored form and is always on.
22
+ - A literal-false `assert(false, ...)` or `expect(false, ...)` is treated as terminating control flow (like `static_assert(false, ...)`), so it satisfies contexts that require guaranteed exit, such as the `else:` block of a `let ... else:` guard.
23
+
24
+ ## 2. Tests
25
+
26
+ Tests are ordinary functions annotated with `@[test]`. They take no parameters and return `void`; a failing assertion aborts the test.
27
+
28
+ ```mt
29
+ function square(x: int) -> int:
30
+ return x * x
31
+
32
+ @[test]
33
+ function test_square() -> void:
34
+ expect_eq(square(3), 9)
35
+ expect(square(4) > 0, "square must be positive")
36
+ ```
37
+
38
+ Rules:
39
+
40
+ - A test file must not define `main`; `mtc test` synthesizes the entry point.
41
+ - A function annotated with `@[test] @[expect_fatal]` is a death test: it must abort (via `fatal`, a failed assertion, or a failed safety check) to pass.
42
+
43
+ ## 3. Running Tests
44
+
45
+ ```sh
46
+ mtc test PATH # run @[test] functions in one file
47
+ mtc test DIR # recursively run every test file under a directory
48
+ ```
49
+
50
+ `mtc test` builds one runner binary per test file and runs each test in its own process, so an aborting assertion is isolated to its own test and cannot suppress the results of its siblings. Passing tests print `ok - name`; failures print `FAIL - name: message`. The run is sandboxed with a wall-clock timeout (default 30s) and an address-space memory cap (default 1024 MB).
51
+
52
+ Options:
53
+
54
+ - `--timeout SECONDS` — per-test wall-clock timeout
55
+ - `--mem MB` — per-test address-space memory cap
56
+ - `--jobs N` — build and run N files in parallel (output stays in file order)
57
+ - `-n SUBSTRING` — run only tests whose name contains SUBSTRING
58
+ - `--format tap|junit` — machine-readable results for CI
59
+ - `--sanitize` — build with AddressSanitizer/UBSan; any sanitizer error fails the run
60
+ - `--profile debug|release`, `--platform linux|windows|wasm`, `--cc COMPILER` — build controls, same as `mtc build`
61
+
62
+ A file containing `# expect-error: <text>` is a compile-fail fixture: `mtc test` requires the compiler to reject it with a diagnostic containing that text, instead of running it.
63
+
64
+ ## 4. Example
65
+
66
+ ```mt
67
+ # tests/vec_test.mt
68
+ import std.vec as vec
69
+
70
+ @[test]
71
+ function test_vec_push_and_len() -> void:
72
+ var values = vec.Vec[int].create()
73
+ defer: values.release()
74
+ values.push(1)
75
+ values.push(2)
76
+ expect_eq(values.len(), 2)
77
+
78
+ @[test]
79
+ @[expect_fatal]
80
+ function test_vec_oob_aborts() -> void:
81
+ var values = vec.Vec[int].create()
82
+ defer: values.release()
83
+ values.push(1)
84
+ values.at(5)
85
+ ```
86
+
87
+ ```sh
88
+ mtc test tests
89
+ ```
90
+
91
+ See the `mtc test --help` text for the current full option list.
data/lib/milk_tea/base.rb CHANGED
@@ -3,7 +3,7 @@
3
3
  require "pathname"
4
4
 
5
5
  module MilkTea
6
- VERSION = "0.4.2"
6
+ VERSION = "0.4.22"
7
7
 
8
8
  def self.root
9
9
  @root ||= Pathname.new(File.expand_path("../..", __dir__))
@@ -254,7 +254,16 @@ module MilkTea
254
254
  end
255
255
 
256
256
  def fatal_expression?(expression)
257
- expression.is_a?(AST::Call) && expression.callee.is_a?(AST::Identifier) && expression.callee.name == "fatal"
257
+ return true if expression.is_a?(AST::Call) && expression.callee.is_a?(AST::Identifier) && expression.callee.name == "fatal"
258
+
259
+ # A literal-false `assert`/`expect` always aborts, so it terminates the
260
+ # current control flow like a `fatal` call (matching static_assert(false)).
261
+ expression.is_a?(AST::Call) &&
262
+ expression.callee.is_a?(AST::Identifier) &&
263
+ %w[assert expect].include?(expression.callee.name) &&
264
+ (first_arg = expression.arguments.first) &&
265
+ first_arg.value.is_a?(AST::BooleanLiteral) &&
266
+ first_arg.value.value == false
258
267
  end
259
268
 
260
269
  def assignment_target_reads(target, operator)
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MilkTea
4
+ module Lowering
5
+ module Assertions
6
+ ASSERT_CALL_NAMES = %w[assert expect expect_eq expect_ne].freeze
7
+
8
+ # Lowers an `assert`/`expect`/`expect_eq`/`expect_ne` expression statement
9
+ # into an `IR::IfStmt` whose then-branch aborts via `fatal`. The message is
10
+ # only evaluated on the failing path so unused messages cost nothing.
11
+ # Returns `nil` when the statement is not one of the assertion calls so the
12
+ # caller can fall through to ordinary expression-statement lowering.
13
+ def lower_assert_like_statement(statement, env:)
14
+ kind = assert_like_call_kind(statement.expression)
15
+ return nil unless kind
16
+
17
+ arguments = statement.expression.arguments
18
+ line = statement.expression.line
19
+ column = statement.expression.column
20
+ default_message = default_assert_message(kind, statement.line)
21
+
22
+ condition_ast = nil
23
+ message = nil
24
+ case kind
25
+ when "assert", "expect"
26
+ condition_ast = unary_expression("not", arguments.fetch(0).value, line:, column:)
27
+ message = arguments.length > 1 ? arguments.fetch(1).value : string_literal(default_message, line:, column:)
28
+ when "expect_eq"
29
+ condition_ast = binary_expression("!=", arguments.fetch(0).value, arguments.fetch(1).value, line:, column:)
30
+ message = arguments.length > 2 ? arguments.fetch(2).value : string_literal(default_message, line:, column:)
31
+ when "expect_ne"
32
+ condition_ast = binary_expression("==", arguments.fetch(0).value, arguments.fetch(1).value, line:, column:)
33
+ message = arguments.length > 2 ? arguments.fetch(2).value : string_literal(default_message, line:, column:)
34
+ end
35
+
36
+ # Hoist inline-proc/foreign-temporary setup out of the condition so the
37
+ # failure check itself stays a plain `if (!cond)` in C.
38
+ setup, prepared_condition, cleanups = prepare_expression_with_cleanups(
39
+ condition_ast,
40
+ env:,
41
+ expected_type: @ctx.types.fetch("bool"),
42
+ )
43
+
44
+ [
45
+ *setup,
46
+ IR::IfStmt.new(
47
+ condition: lower_expression(prepared_condition, env:, expected_type: @ctx.types.fetch("bool")),
48
+ then_body: [lower_fatal_expression_statement(message, line:, column:, env:)],
49
+ else_body: [],
50
+ ),
51
+ *cleanups.flat_map(&:itself),
52
+ ]
53
+ end
54
+
55
+ def assert_like_call_kind(expression)
56
+ return nil unless expression.is_a?(AST::Call)
57
+ return nil unless expression.callee.is_a?(AST::Identifier)
58
+ return nil unless ASSERT_CALL_NAMES.include?(expression.callee.name)
59
+
60
+ expression.callee.name
61
+ end
62
+
63
+ def default_assert_message(kind, line)
64
+ path = @ctx.current_analysis_path.to_s
65
+ case kind
66
+ when "assert" then "assertion failed at #{path}:#{line}"
67
+ when "expect" then "expectation failed at #{path}:#{line}"
68
+ when "expect_eq" then "expect_eq failed: values are not equal at #{path}:#{line}"
69
+ when "expect_ne" then "expect_ne failed: values are equal at #{path}:#{line}"
70
+ end
71
+ end
72
+
73
+ def lower_fatal_expression_statement(message, line:, column:, env:)
74
+ fatal_call = AST::Call.new(
75
+ callee: AST::Identifier.new(name: "fatal", line:, column:),
76
+ arguments: [AST::Argument.new(name: nil, value: message, line:, column:)],
77
+ line:,
78
+ column:,
79
+ )
80
+ IR::ExpressionStmt.new(
81
+ expression: lower_expression(fatal_call, env:, expected_type: @ctx.types.fetch("void")),
82
+ line:,
83
+ path: @ctx.current_analysis_path,
84
+ )
85
+ end
86
+
87
+ def unary_expression(operator, operand, line:, column:)
88
+ AST::UnaryOp.new(operator:, operand:, line:, column:)
89
+ end
90
+
91
+ def binary_expression(operator, left, right, line:, column:)
92
+ AST::BinaryOp.new(operator:, left:, right:, line:, column:)
93
+ end
94
+
95
+ def string_literal(value, line:, column:)
96
+ AST::StringLiteral.new(lexeme: value.inspect, value:, cstring: false, line:, column:)
97
+ end
98
+ end
99
+ end
100
+ end
@@ -422,6 +422,11 @@ module MilkTea
422
422
  end
423
423
 
424
424
  def lower_expression_stmt(statement, lowered:, local_defers:, local_env:)
425
+ if (assert_statements = lower_assert_like_statement(statement, env: local_env))
426
+ lowered.concat(assert_statements)
427
+ return
428
+ end
429
+
425
430
  if (format_sink_statements = lower_explicit_format_sink_expression_statement(statement.expression, env: local_env, line: statement.line))
426
431
  lowered.concat(format_sink_statements)
427
432
  return
@@ -271,6 +271,8 @@ module MilkTea
271
271
  )
272
272
  when :zero
273
273
  IR::ZeroInit.new(type:)
274
+ when :assert, :expect, :expect_eq, :expect_ne
275
+ raise LoweringError.new("#{kind} must be used as a statement", line: 0, column: 0, path: @ctx.current_analysis_path)
274
276
  when :fatal
275
277
  argument = expression.arguments.fetch(0)
276
278
  message_type = infer_expression_type(argument.value, env:)
@@ -6,6 +6,10 @@ module MilkTea
6
6
  include CompileTime::MethodFolding
7
7
  PASS_THROUGH_BUILTINS = {
8
8
  "fatal" => :fatal,
9
+ "assert" => :assert,
10
+ "expect" => :expect,
11
+ "expect_eq" => :expect_eq,
12
+ "expect_ne" => :expect_ne,
9
13
  "ref_of" => :ref_of,
10
14
  "const_ptr_of" => :const_ptr_of,
11
15
  "read" => :read,
@@ -26,6 +26,7 @@
26
26
 
27
27
  require_relative "lowering/scans"
28
28
  require_relative "lowering/declarations"
29
+ require_relative "lowering/assertions"
29
30
  require_relative "lowering/events"
30
31
  require_relative "lowering/functions"
31
32
  require_relative "lowering/async/analysis"
@@ -107,6 +108,7 @@ module MilkTea
107
108
  include Lowering::Functions
108
109
  include Lowering::Async
109
110
  include Lowering::Block
111
+ include Lowering::Assertions
110
112
  include Lowering::Proc
111
113
  include Lowering::Loops
112
114
  include Lowering::Expressions
@@ -834,6 +834,37 @@ module MilkTea
834
834
 
835
835
  raise_sema_error("fatal expects str or cstr, got #{message_type}")
836
836
  end
837
+ def check_assert_call(kind, arguments, scopes:)
838
+ name = kind.to_s
839
+ raise_sema_error("#{name} does not support named arguments") if arguments.any?(&:name)
840
+ raise_sema_error("#{name} expects 1 or 2 arguments, got #{arguments.length}") unless arguments.length == 1 || arguments.length == 2
841
+
842
+ condition_type = infer_expression(arguments.fetch(0).value, scopes:, expected_type: @ctx.types.fetch("bool"))
843
+ ensure_assignable!(condition_type, @ctx.types.fetch("bool"), "#{name} condition must be bool, got #{condition_type}", expression: arguments.fetch(0).value)
844
+ check_assert_message(name, arguments[1]&.value, scopes:) if arguments.length == 2
845
+
846
+ @ctx.types.fetch("void")
847
+ end
848
+ def check_expect_eq_call(kind, arguments, scopes:)
849
+ name = kind.to_s
850
+ raise_sema_error("#{name} does not support named arguments") if arguments.any?(&:name)
851
+ raise_sema_error("#{name} expects 2 or 3 arguments, got #{arguments.length}") unless arguments.length == 2 || arguments.length == 3
852
+
853
+ left = arguments.fetch(0).value
854
+ right = arguments.fetch(1).value
855
+ operator = kind == :expect_ne ? "!=" : "=="
856
+ comparison = AST::BinaryOp.new(operator:, left:, right:, line: nil, column: nil)
857
+ infer_binary(comparison, scopes:, expected_type: @ctx.types.fetch("bool"))
858
+ check_assert_message(name, arguments[2]&.value, scopes:) if arguments.length == 3
859
+
860
+ @ctx.types.fetch("void")
861
+ end
862
+ def check_assert_message(name, message, scopes:)
863
+ return unless message
864
+
865
+ message_type = infer_expression(message, scopes:, expected_type: @ctx.types.fetch("str"))
866
+ raise_sema_error("#{name} message must be str or cstr, got #{message_type}") unless string_like_type?(message_type)
867
+ end
837
868
  def check_get_call(arguments, scopes:)
838
869
  raise_sema_error("get does not support named arguments") if arguments.any?(&:name)
839
870
  raise_sema_error("get expects 2 arguments, got #{arguments.length}") unless arguments.length == 2
@@ -1043,6 +1043,10 @@ module MilkTea
1043
1043
  check_order_call(callable, expression.arguments, scopes:)
1044
1044
  when :fatal
1045
1045
  check_fatal_call(expression.arguments, scopes:)
1046
+ when :assert, :expect
1047
+ check_assert_call(callable_kind, expression.arguments, scopes:)
1048
+ when :expect_eq, :expect_ne
1049
+ check_expect_eq_call(callable_kind, expression.arguments, scopes:)
1046
1050
  when :ref_of
1047
1051
  check_ref_of_call(expression.arguments, scopes:)
1048
1052
  when :const_ptr_of
@@ -1318,6 +1322,10 @@ module MilkTea
1318
1322
 
1319
1323
  return [:function, @ctx.top_level_functions.fetch(callee.name), nil] if @ctx.top_level_functions.key?(callee.name)
1320
1324
  return [:fatal, nil, nil] if callee.name == "fatal"
1325
+ return [:assert, nil, nil] if callee.name == "assert"
1326
+ return [:expect, nil, nil] if callee.name == "expect"
1327
+ return [:expect_eq, nil, nil] if callee.name == "expect_eq"
1328
+ return [:expect_ne, nil, nil] if callee.name == "expect_ne"
1321
1329
  return [:ref_of, nil, nil] if callee.name == "ref_of"
1322
1330
  return [:const_ptr_of, nil, nil] if callee.name == "const_ptr_of"
1323
1331
  return [:read, nil, nil] if callee.name == "read"
@@ -399,21 +399,14 @@ module MilkTea
399
399
  return 1
400
400
  end
401
401
 
402
- testing_import = ast.imports.find { |import| import.path.parts == %w[std testing] }
403
- unless testing_import
404
- @err.puts("a test file must import std.testing: #{path}")
405
- return 1
406
- end
407
- testing_alias = testing_import.alias_name || testing_import.path.parts.last
408
-
409
402
  death_tests, normal_tests = tests.partition { |test| expect_fatal_attribute?(test) }
410
403
 
411
404
  exit_code = 0
412
405
 
413
406
  unless normal_tests.empty?
414
407
  runner_source = source.dup
415
- runner_source << "\n\n" << test_runner_main(testing_alias, normal_tests.map(&:name))
416
- exit_code = run_synthesized_tests(path, runner_source, options:, locked:)
408
+ runner_source << "\n\n" << test_runner_main(normal_tests.map(&:name))
409
+ exit_code = run_normal_tests(path, runner_source, normal_tests.map(&:name), options:, locked:)
417
410
  end
418
411
 
419
412
  death_tests.each do |death_test|
@@ -459,30 +452,53 @@ module MilkTea
459
452
  def death_test_runner_main(test_name)
460
453
  [
461
454
  "function main() -> int:",
462
- " match #{test_name}():",
463
- " Result.success:",
464
- " return 0",
465
- " Result.failure:",
466
- " return 0",
455
+ " #{test_name}()",
456
+ " return 0",
467
457
  ].join("\n") + "\n"
468
458
  end
469
459
 
470
- def test_runner_main(testing_alias, test_names)
471
- lines = ["function main() -> int:"]
472
- lines << " var __mt_test_stats = #{testing_alias}.Stats.create()"
460
+ # The runner binary runs exactly one test per invocation, selected by
461
+ # `argv[1]`. This gives per-test isolation: an aborting assertion in one
462
+ # test cannot suppress the results of its siblings.
463
+ def test_runner_main(test_names)
464
+ lines = ["function main(args: span[str]) -> int:"]
465
+ lines << " let which = if args.len > 1: args[1] else: \"\""
466
+ lines << " match which:"
473
467
  test_names.each do |name|
474
- lines << " __mt_test_stats = #{testing_alias}.record(__mt_test_stats, #{name.inspect}, #{name}())"
468
+ lines << " \"#{name}\":"
469
+ lines << " #{name}()"
475
470
  end
476
- lines << " return #{testing_alias}.summarize(__mt_test_stats)"
471
+ lines << " _:"
472
+ lines << " return 2"
473
+ lines << " return 0"
477
474
  lines.join("\n") + "\n"
478
475
  end
479
476
 
480
- def run_synthesized_tests(source_path, runner_source, options:, locked:)
477
+ def run_normal_tests(source_path, runner_source, test_names, options:, locked:)
481
478
  with_synthesized_binary(source_path, runner_source, options:, locked:) do |binary_path|
482
- run_test_binary(binary_path)
479
+ exit_code = 0
480
+ test_names.each do |name|
481
+ output, status, timed_out = spawn_sandboxed(binary_path, [name])
482
+ if timed_out
483
+ @out.puts("FAIL - #{name}: timed out")
484
+ exit_code = 1
485
+ elsif status&.exitstatus == 0
486
+ @out.puts("ok - #{name}")
487
+ else
488
+ @out.puts("FAIL - #{name}: #{first_fail_message(output)}")
489
+ exit_code = 1
490
+ end
491
+ @out.flush if @out.respond_to?(:flush)
492
+ end
493
+ exit_code
483
494
  end
484
495
  end
485
496
 
497
+ def first_fail_message(output)
498
+ line = output.to_s.lines.map(&:chomp).find { |entry| !entry.strip.empty? }
499
+ line.nil? || line.strip.empty? ? "test aborted" : line.strip
500
+ end
501
+
486
502
  def with_synthesized_binary(source_path, runner_source, options:, locked:)
487
503
  directory = File.dirname(File.expand_path(source_path))
488
504
  runner_path = File.join(directory, "__mt_test_runner_#{Process.pid}.mt")
@@ -508,30 +524,13 @@ module MilkTea
508
524
  end
509
525
  end
510
526
 
511
- def run_test_binary(binary_path)
512
- output, status, timed_out = spawn_sandboxed(binary_path)
513
- @out.write(output)
514
- @out.flush if @out.respond_to?(:flush)
515
-
516
- if timed_out
517
- @err.puts("test run timed out after #{@test_timeout_seconds || TEST_RUN_TIMEOUT_SECONDS}s")
518
- return 1
519
- end
520
- if status&.signaled?
521
- @err.puts("test run crashed (signal #{status.termsig})")
522
- return 1
523
- end
524
-
525
- status&.exitstatus || 1
526
- end
527
-
528
- def spawn_sandboxed(binary_path)
527
+ def spawn_sandboxed(binary_path, args = [])
529
528
  timeout_seconds = @test_timeout_seconds || TEST_RUN_TIMEOUT_SECONDS
530
529
  memory_bytes = @test_memory_bytes || TEST_RUN_MEMORY_LIMIT_BYTES
531
530
  reader, writer = IO.pipe
532
531
  spawn_options = { out: writer, err: writer, pgroup: true }
533
532
  spawn_options[:rlimit_as] = memory_bytes unless @test_sanitize
534
- pid = Process.spawn(binary_path, **spawn_options)
533
+ pid = Process.spawn(binary_path, *args, **spawn_options)
535
534
  writer.close
536
535
 
537
536
  status = nil
@@ -597,10 +597,11 @@ module MilkTea
597
597
  root), recursively discovers every .mt file that contains @[test] functions,
598
598
  runs each as its own test binary, and prints an aggregate summary.
599
599
 
600
- Functions annotated with @[test] must take no parameters and return
601
- std.testing.Check. `mtc test` synthesizes a runner that invokes each
602
- test through std.testing and reports the results; a test file must import
603
- std.testing and must not define `main`.
600
+ Functions annotated with @[test] must take no parameters and return void;
601
+ a failing test aborts via the `assert`/`expect`/`expect_eq`/`expect_ne`
602
+ intrinsics. `mtc test` synthesizes a runner that invokes each test in its
603
+ own process (for isolation) and reports the results. A test file must not
604
+ define `main`.
604
605
 
605
606
  Each test binary runs under a wall-clock timeout and an address-space
606
607
  memory cap so a hanging or runaway test cannot stall or exhaust the host.
@@ -105,14 +105,25 @@ module MilkTea
105
105
  def terminating_expression?(expression)
106
106
  case expression
107
107
  when AST::Call
108
- terminating_callee?(expression.callee) || static_assert_false?(expression)
108
+ terminating_callee?(expression.callee) || static_assert_false?(expression) || assertion_false?(expression)
109
109
  when AST::Specialization
110
- terminating_callee?(expression.callee) || static_assert_false?(expression)
110
+ terminating_callee?(expression.callee) || static_assert_false?(expression) || assertion_false?(expression)
111
111
  else
112
112
  false
113
113
  end
114
114
  end
115
115
 
116
+ def assertion_false?(expression)
117
+ return false unless expression.is_a?(AST::Call)
118
+
119
+ callee = expression.callee
120
+ return false unless callee.is_a?(AST::Identifier)
121
+ return false unless %w[assert expect].include?(callee.name)
122
+
123
+ first_arg = expression.arguments.first
124
+ first_arg.is_a?(AST::BooleanLiteral) && first_arg.value == false
125
+ end
126
+
116
127
  def static_assert_false?(expression)
117
128
  return false unless expression.callee.is_a?(AST::Identifier)
118
129
  return false unless expression.callee.name == "static_assert"
@@ -209,6 +209,7 @@ module MilkTea
209
209
  check_prefer_try(statement.expression, statement.arms)
210
210
  end
211
211
  when AST::UnsafeStmt
212
+ check_redundant_unsafe(statement)
212
213
  @unsafe_depth += 1
213
214
  with_scope { visit_statement_list(statement.body) }
214
215
  @unsafe_depth -= 1
@@ -314,6 +315,7 @@ module MilkTea
314
315
  check_prefer_or_pattern(expression.arms, body_of: ->(arm) { arm.value })
315
316
  end
316
317
  when AST::UnsafeExpr
318
+ check_redundant_unsafe(expression)
317
319
  @unsafe_depth += 1
318
320
  visit_expression(expression.expression)
319
321
  @unsafe_depth -= 1
@@ -362,6 +364,21 @@ module MilkTea
362
364
  nil
363
365
  end
364
366
  end
367
+ def check_redundant_unsafe(node)
368
+ return unless @sema_facts
369
+ return if @sema_facts.required_unsafe_lines.include?(node.line)
370
+
371
+ @warnings << Warning.new(
372
+ path: @path,
373
+ line: node.line,
374
+ column: node.column,
375
+ length: "unsafe".length,
376
+ code: "redundant-unsafe",
377
+ message: "unsafe block contains no unsafe operations and can be removed",
378
+ severity: :hint,
379
+ )
380
+ end
381
+
365
382
  def visit_type_argument(argument)
366
383
  visit_expression(argument.value) if argument.respond_to?(:value)
367
384
  end
@@ -22,17 +22,17 @@ module MilkTea
22
22
  borrow-and-mutate
23
23
  constant-condition
24
24
  dead-assignment
25
- duplicate-if-condition
26
25
  directional-ffi-arg
27
26
  doc-tag
27
+ duplicate-if-condition
28
28
  event-capacity
29
29
  line-too-long
30
30
  loop-single-iteration
31
31
  missing-return
32
32
  noop-compound-assignment
33
- platform-api-drift
34
- owning-release-leak
35
33
  owning-release-double
34
+ owning-release-leak
35
+ platform-api-drift
36
36
  prefer-conditional-expression
37
37
  prefer-inline-if
38
38
  prefer-inline-methods
@@ -51,6 +51,7 @@ module MilkTea
51
51
  redundant-null-check
52
52
  redundant-return
53
53
  redundant-type-annotation
54
+ redundant-unsafe
54
55
  reserved-primitive-name
55
56
  self-assignment
56
57
  self-comparison
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mt-lang
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.4.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Long (Teefan) Tran
@@ -150,6 +150,7 @@ files:
150
150
  - docs/language-design.md
151
151
  - docs/language-manual.md
152
152
  - docs/lsp-performance.md
153
+ - docs/testing.md
153
154
  - lib/milk_tea.rb
154
155
  - lib/milk_tea/base.rb
155
156
  - lib/milk_tea/bindings.rb
@@ -233,6 +234,7 @@ files:
233
234
  - lib/milk_tea/core/lexer/trivia.rb
234
235
  - lib/milk_tea/core/lowering.rb
235
236
  - lib/milk_tea/core/lowering/artifacts.rb
237
+ - lib/milk_tea/core/lowering/assertions.rb
236
238
  - lib/milk_tea/core/lowering/async/analysis.rb
237
239
  - lib/milk_tea/core/lowering/async/async_lowering.rb
238
240
  - lib/milk_tea/core/lowering/async/frame_builder.rb
@@ -609,7 +611,6 @@ files:
609
611
  - std/sync.mt
610
612
  - std/tar.mt
611
613
  - std/terminal.mt
612
- - std/testing.mt
613
614
  - std/thread.mt
614
615
  - std/time.mt
615
616
  - std/timer.mt
@@ -629,7 +630,7 @@ metadata:
629
630
  homepage_uri: https://teefan.github.io/mt-lang/
630
631
  source_code_uri: https://github.com/teefan/mt-lang
631
632
  post_install_message: |
632
- Milk Tea 0.4.2 installed!
633
+ Milk Tea 0.4.22 installed!
633
634
 
634
635
  System requirements:
635
636
  - A C compiler (gcc or clang) must be available on PATH
data/std/testing.mt DELETED
@@ -1,266 +0,0 @@
1
- # Standard library: testing core (T0 prototype)
2
- #
3
- # Minimal, in-language unit-testing surface — see docs/testing.md (§5, T0).
4
- # A test function returns `Check` and propagates the first failure with `?`:
5
- #
6
- # import std.testing as t
7
- #
8
- # function test_math() -> t.Check:
9
- # t.expect_equal_int(2 + 2, 4)?
10
- # return t.ok()
11
- #
12
- # A hand-written runner (see docs/testing.md §6 for future compiler-driven
13
- # discovery) calls `record` per test and `summarize` at the end:
14
- #
15
- # function main() -> int:
16
- # var stats = t.Stats.create()
17
- # stats = t.record(stats, "math", test_math())
18
- # return t.summarize(stats)
19
-
20
- import std.string as string
21
- import std.fmt as fmt
22
- import std.stdio as stdio
23
- import std.str
24
- import std.hash
25
-
26
- # A test outcome: success carries no meaningful value; failure carries a
27
- # `Failure`. `Result` is used (not a bespoke variant) so `?` propagation works.
28
-
29
- # A failed (or skipped) expectation. `message` is owned and must be released by
30
- # whoever consumes it (the runner does this in `record`). `is_skip` distinguishes
31
- # a skip from a real assertion failure.
32
- public struct Failure:
33
- message: string.String
34
- is_skip: bool
35
-
36
- public type Check = Result[bool, Failure]
37
-
38
-
39
- # Tally of outcomes for a run. Value type; `record` returns an updated copy.
40
- public struct Stats:
41
- passed: int
42
- failed: int
43
- skipped: int
44
-
45
-
46
- extending Stats:
47
- public static function create() -> Stats:
48
- return Stats(passed = 0, failed = 0, skipped = 0)
49
-
50
-
51
- # ── Outcome constructors ──────────────────────────────────────────────────
52
-
53
- public function ok() -> Check:
54
- return Result[bool, Failure].success(value = true)
55
-
56
-
57
- public function fail(message: str) -> Check:
58
- return Result[bool, Failure].failure(error = Failure(message = string.String.from_str(message), is_skip = false))
59
-
60
-
61
- public function skip(reason: str) -> Check:
62
- return Result[bool, Failure].failure(error = Failure(message = string.String.from_str(reason), is_skip = true))
63
-
64
-
65
- # ── Expectations ──────────────────────────────────────────────────────────
66
-
67
- public function expect(condition: bool, message: str) -> Check:
68
- if condition:
69
- return ok()
70
-
71
- return fail(message)
72
-
73
-
74
- public function expect_true(condition: bool) -> Check:
75
- return expect(condition, "expected true")
76
-
77
-
78
- public function expect_false(condition: bool) -> Check:
79
- return expect(not condition, "expected false")
80
-
81
-
82
- public function expect_equal_int(actual: int, expected: int) -> Check:
83
- if actual == expected:
84
- return ok()
85
-
86
- var message = string.String.create()
87
- message.append("expected ")
88
- fmt.append_int(ref_of(message), expected)
89
- message.append(", got ")
90
- fmt.append_int(ref_of(message), actual)
91
- return Result[bool, Failure].failure(error = Failure(message = message, is_skip = false))
92
-
93
-
94
- public function expect_equal_str(actual: str, expected: str) -> Check:
95
- var actual_string = string.String.from_str(actual)
96
- var expected_string = string.String.from_str(expected)
97
- let same = actual_string.equal(expected_string)
98
- actual_string.release()
99
- expected_string.release()
100
- if same:
101
- return ok()
102
-
103
- var message = string.String.create()
104
- message.append("expected [")
105
- message.append(expected)
106
- message.append("], got [")
107
- message.append(actual)
108
- message.append("]")
109
- return Result[bool, Failure].failure(error = Failure(message = message, is_skip = false))
110
-
111
-
112
- public function expect_equal_bool(actual: bool, expected: bool) -> Check:
113
- if actual == expected:
114
- return ok()
115
-
116
- var message = string.String.create()
117
- message.append("expected ")
118
- fmt.append_bool(ref_of(message), expected)
119
- message.append(", got ")
120
- fmt.append_bool(ref_of(message), actual)
121
- return Result[bool, Failure].failure(error = Failure(message = message, is_skip = false))
122
-
123
-
124
- public function expect_not_equal_int(actual: int, expected: int) -> Check:
125
- if actual != expected:
126
- return ok()
127
-
128
- var message = string.String.create()
129
- message.append("expected not ")
130
- fmt.append_int(ref_of(message), expected)
131
- message.append(", got ")
132
- fmt.append_int(ref_of(message), actual)
133
- return Result[bool, Failure].failure(error = Failure(message = message, is_skip = false))
134
-
135
-
136
- public function expect_not_equal_str(actual: str, expected: str) -> Check:
137
- var actual_string = string.String.from_str(actual)
138
- var expected_string = string.String.from_str(expected)
139
- let same = actual_string.equal(expected_string)
140
- actual_string.release()
141
- expected_string.release()
142
- if not same:
143
- return ok()
144
-
145
- var message = string.String.create()
146
- message.append("expected not [")
147
- message.append(expected)
148
- message.append("], got [")
149
- message.append(actual)
150
- message.append("]")
151
- return Result[bool, Failure].failure(error = Failure(message = message, is_skip = false))
152
-
153
-
154
- public function expect_not_equal_bool(actual: bool, expected: bool) -> Check:
155
- if actual != expected:
156
- return ok()
157
-
158
- var message = string.String.create()
159
- message.append("expected not ")
160
- fmt.append_bool(ref_of(message), expected)
161
- message.append(", got ")
162
- fmt.append_bool(ref_of(message), actual)
163
- return Result[bool, Failure].failure(error = Failure(message = message, is_skip = false))
164
-
165
-
166
- # Generic equality over any type with a canonical `T.equal` hook: primitives
167
- # (import std.hash), `str` (import std.str), and user structs that define `equal`
168
- # (or delegate to std.hash.equal_struct). On failure the actual/expected values
169
- # are rendered via `std.fmt.format_value`, so `T` must be a primitive or a struct
170
- # whose fields are themselves `format_value`-renderable.
171
- public function expect_equal[T](actual: T, expected: T) -> Check:
172
- if equal[T](actual, expected):
173
- return ok()
174
-
175
- var message = string.String.create()
176
- message.append("expected ")
177
- fmt.format_value[T](ref_of(message), const_ptr_of(expected))
178
- message.append(", got ")
179
- fmt.format_value[T](ref_of(message), const_ptr_of(actual))
180
- return Result[bool, Failure].failure(error = Failure(message = message, is_skip = false))
181
-
182
-
183
- public function expect_some[T](option: Option[T]) -> Check:
184
- if option.is_some():
185
- return ok()
186
-
187
- return fail("expected Option.some, got Option.none")
188
-
189
-
190
- public function expect_none[T](option: Option[T]) -> Check:
191
- if option.is_none():
192
- return ok()
193
-
194
- return fail("expected Option.none, got Option.some")
195
-
196
-
197
- public function expect_null[T](pointer: const_ptr[T]?) -> Check:
198
- if pointer == null:
199
- return ok()
200
-
201
- return fail("expected null pointer, got non-null")
202
-
203
-
204
- public function expect_not_null[T](pointer: const_ptr[T]?) -> Check:
205
- if pointer != null:
206
- return ok()
207
-
208
- return fail("expected non-null pointer, got null")
209
-
210
-
211
- public function expect_error[T, E](result: Result[T, E]) -> Check:
212
- if result.is_failure():
213
- return ok()
214
-
215
- return fail("expected Result.failure, got Result.success")
216
-
217
-
218
- # ── Runner (hand-written; compiler discovery is a later phase) ─────────────
219
-
220
- public function record(stats: Stats, name: str, outcome: Check) -> Stats:
221
- match outcome:
222
- Result.success:
223
- var line = string.String.create()
224
- line.append("ok - ")
225
- line.append(name)
226
- stdio.print_line(line.as_str())
227
- line.release()
228
- return Stats(passed = stats.passed + 1, failed = stats.failed, skipped = stats.skipped)
229
- Result.failure as payload:
230
- let failure = payload.error
231
- var line = string.String.create()
232
- if failure.is_skip:
233
- line.append("skip - ")
234
- else:
235
- line.append("FAIL - ")
236
-
237
- line.append(name)
238
- line.append(": ")
239
- line.append(failure.message.as_str())
240
- stdio.print_line(line.as_str())
241
- line.release()
242
-
243
- var owned_message = failure.message
244
- owned_message.release()
245
-
246
- if failure.is_skip:
247
- return Stats(passed = stats.passed, failed = stats.failed, skipped = stats.skipped + 1)
248
-
249
- return Stats(passed = stats.passed, failed = stats.failed + 1, skipped = stats.skipped)
250
-
251
-
252
- public function summarize(stats: Stats) -> int:
253
- var line = string.String.create()
254
- line.append("passed=")
255
- fmt.append_int(ref_of(line), stats.passed)
256
- line.append(" failed=")
257
- fmt.append_int(ref_of(line), stats.failed)
258
- line.append(" skipped=")
259
- fmt.append_int(ref_of(line), stats.skipped)
260
- stdio.print_line(line.as_str())
261
- line.release()
262
-
263
- if stats.failed > 0:
264
- return 1
265
-
266
- return 0