native test framework

This commit is contained in:
2026-07-17 22:36:17 +02:00
parent cedc63b28b
commit b09787029d
23 changed files with 141197 additions and 139688 deletions
+6
View File
@@ -18,6 +18,8 @@ roadmap and milestone history.
- `hide` makes any named top-level declaration file-local; declarations are public by default, - `hide` makes any named top-level declaration file-local; declarations are public by default,
leading underscores are ordinary identifier characters, and imports are always file-local leading underscores are ordinary identifier characters, and imports are always file-local
- relative `.h` imports as synthetic C header package namespaces - relative `.h` imports as synthetic C header package namespaces
- native `name test { ... }` declarations with implicit fallible-void results, plus anonymous
transitive `test import "..."` discovery used only by test builds
- root `main` validation with trap executable recovery for missing or unusable entry points - root `main` validation with trap executable recovery for missing or unusable entry points
### scalar, aggregate, and pointer types ### scalar, aggregate, and pointer types
@@ -180,6 +182,8 @@ as `math.divfloor(a, b)` resolve to ordinary functions.
- `std/io` explicit `Io` capabilities, provider-bound `Reader`/`Writer` handles, existing-file open/close operations, allocation-free `write_all`, and comptime-expanded writer-first `print`; formatting supports natural `{}`, byte `{s}`, decimal `{d}`, integer `{b}` / `{o}` / `{x}` / `{X}`, byte-character `{c}`, scientific float `{e}`, and `{{` / `}}`, with malformed formats and incompatible tuple fields rejected at comptime - `std/io` explicit `Io` capabilities, provider-bound `Reader`/`Writer` handles, existing-file open/close operations, allocation-free `write_all`, and comptime-expanded writer-first `print`; formatting supports natural `{}`, byte `{s}`, decimal `{d}`, integer `{b}` / `{o}` / `{x}` / `{X}`, byte-character `{c}`, scientific float `{e}`, and `{{` / `}}`, with malformed formats and incompatible tuple fields rejected at comptime
- entry points are either `main func() ...` or `main func(init process.Init) ...`; `std/process.Init` carries startup capabilities, currently only `io`, while the system provider remains hidden inside `std/io` - entry points are either `main func() ...` or `main func(init process.Init) ...`; `std/process.Init` carries startup capabilities, currently only `io`, while the system provider remains hidden inside `std/io`
- `std/debug.print` is an allocation-free, failure-ignoring stderr escape hatch independent of `process.Init` - `std/debug.print` is an allocation-free, failure-ignoring stderr escape hatch independent of `process.Init`
- `std/testing` supplies fallible `expect` and expected-first `expect_equal`; direct calls through
an alias of exactly `@std/testing` receive compiler-injected source locations
### compiler behavior ### compiler behavior
@@ -188,6 +192,8 @@ as `math.divfloor(a, b)` resolve to ordinary functions.
- static, eager runtime, mutable runtime, and deferred problematic globals with cycle diagnostics - static, eager runtime, mutable runtime, and deferred problematic globals with cycle diagnostics
- demand-driven LLVM declarations for referenced foreign functions - demand-driven LLVM declarations for referenced foreign functions
- root `main` may be parameterless or accept canonical `@std/process Init`; the generated C entry point obtains the hidden system I/O provider and constructs the init value - root `main` may be parameterless or accept canonical `@std/process Init`; the generated C entry point obtains the hidden system I/O provider and constructs the init value
- `brolang test [root]` reuses `build.bro`, discovers only explicit test-import edges, skips the
application entry point, and runs tests sequentially while continuing after assertion failures
- replaceable dynamically loaded libclang C-import backend - replaceable dynamically loaded libclang C-import backend
- C-header import caching by canonical path, target, include paths, and defines - C-header import caching by canonical path, target, include paths, and defines
+21
View File
@@ -73,6 +73,27 @@ linker inputs) and, like those flags, their paths are relative to the invocation
directory. Lists take the address of an array literal; empty lists are written directory. Lists take the address of an array literal; empty lists are written
`&[]`. See `examples/build/` for runnable projects. `&[]`. See `examples/build/` for runnable projects.
Projects can declare tests directly and run them with `brolang test [root]`.
The command reads the same `build.bro`, writes `build/<name>-test`, and reuses
its C link inputs, libraries, include paths, and defines.
```bro
math :: import "../math"
testing :: import "@std/testing"
test import "../math"
addition test {
try testing.expect(math.add(20, 22) == 42)
try testing.expect_equal(42, math.add(20, 22))
}
```
`test import` discovers tests transitively without creating a namespace;
calling package code still requires an ordinary import. Ordinary imports do
not discover dependency tests. Assertions report their source location, a
failure ends only the current test, and the runner continues with the suite.
Relative `.h` imports create synthetic package namespaces backed by libclang: Relative `.h` imports create synthetic package namespaces backed by libclang:
```bro ```bro
+10
View File
@@ -897,6 +897,16 @@
- `tag!` reads or folds a tagged union discriminant, while `tagname!` turns a comptime-known enum - `tag!` reads or folds a tagged union discriminant, while `tagname!` turns a comptime-known enum
value into its immutable field-name string value into its immutable field-name string
41. native test framework (implemented; v1)
- `name test { ... }` declares an implicit `void ! testing.Error` test omitted from executable builds
- anonymous `test import` edges discover dependency tests transitively; ordinary imports remain
separate namespaces and never discover tests
- direct `@std/testing` `expect` and expected-first `expect_equal` calls inject their source locations
- `brolang test` reuses `build.bro`, runs tests sequentially, continues after assertion failures,
prints a summary, and returns failure when a test fails or traps
- filtering, skipping, fixtures, snapshots, parallelism, isolation, allocators, and more assertion
families remain deferred
## A word on unchecked casts ## A word on unchecked casts
For casts that bypass safety checks, Honey provides builtin functions: For casts that bypass safety checks, Honey provides builtin functions:
+8
View File
@@ -227,6 +227,7 @@ Function :: struct {
c_abi: bool, c_abi: bool,
imported: bool, imported: bool,
generated: bool, generated: bool,
test: bool,
file_hidden: bool, file_hidden: bool,
has_body: bool, has_body: bool,
variadic: bool, variadic: bool,
@@ -276,6 +277,7 @@ Import :: struct {
target: Package_Id, target: Package_Id,
valid: bool, valid: bool,
used: bool, used: bool,
test_only: bool,
diagnostic: source.Diagnostic_Id, diagnostic: source.Diagnostic_Id,
} }
@@ -310,9 +312,15 @@ Package :: struct {
path: string, path: string,
name: symbol.Id, name: symbol.Id,
available: bool, available: bool,
test: bool,
kind: Package_Kind, kind: Package_Kind,
} }
Compile_Mode :: enum u8 {
Executable,
Test,
}
Package_Kind :: enum u8 { Package_Kind :: enum u8 {
Native, Native,
C_Header, C_Header,
+69 -24
View File
@@ -43,26 +43,7 @@ destroy_build_config :: proc(cfg: ^BuildConfig) {
delete(cfg.c_options.defines) delete(cfg.c_options.defines)
} }
// run_build implements `brolang build [root]`: it loads and type-checks load_build_config :: proc(project_root: string) -> (BuildConfig, bool) {
// `root/build.bro`, reads its `config` constant, and compiles the program
// package the config names. build.bro is only checked (never lowered/emitted),
// so the config is read straight from the HIR.
run_build :: proc(root: string) -> int {
project_root := root
owns_project_root := false
if len(project_root) == 0 {
found_root, found := find_build_root()
if !found {
fmt.eprintln("brolang build: could not find build.bro in the current directory or any parent")
return 2
}
project_root = found_root
owns_project_root = true
}
defer if owns_project_root {
delete(project_root)
}
sources := source.init_store() sources := source.init_store()
defer source.destroy_store(&sources) defer source.destroy_store(&sources)
diagnostics := source.init_store_diagnostics(&sources) diagnostics := source.init_store_diagnostics(&sources)
@@ -73,7 +54,7 @@ run_build :: proc(root: string) -> int {
arena: vmem.Arena arena: vmem.Arena
if err := vmem.arena_init_growing(&arena); err != nil { if err := vmem.arena_init_growing(&arena); err != nil {
fmt.eprintln("failed to initialize build arena:", err) fmt.eprintln("failed to initialize build arena:", err)
return 2 return {}, false
} }
defer vmem.arena_destroy(&arena) defer vmem.arena_destroy(&arena)
a := vmem.arena_allocator(&arena) a := vmem.arena_allocator(&arena)
@@ -82,7 +63,7 @@ run_build :: proc(root: string) -> int {
if !loaded { if !loaded {
source.print_all(&diagnostics) source.print_all(&diagnostics)
fmt.eprintln("failed to load build root:", project_root) fmt.eprintln("failed to load build root:", project_root)
return 2 return {}, false
} }
// check needs no `main`: it synthesizes a trap main and emits one benign // check needs no `main`: it synthesizes a trap main and emits one benign
// "missing main" diagnostic, which is expected for build.bro. Suppress that // "missing main" diagnostic, which is expected for build.bro. Suppress that
@@ -90,10 +71,32 @@ run_build :: proc(root: string) -> int {
hir_module := checker.check(&ast_module, &diagnostics, &symbols, target.DEFAULT, a) hir_module := checker.check(&ast_module, &diagnostics, &symbols, target.DEFAULT, a)
if build_bro_has_errors(&diagnostics) { if build_bro_has_errors(&diagnostics) {
source.print_all(&diagnostics) source.print_all(&diagnostics)
return {}, false
}
return extract_build_config(&hir_module, &symbols)
}
project_root_for_command :: proc(root, command: string) -> (string, bool, bool) {
if len(root) > 0 {
return root, false, true
}
project_root, found := find_build_root()
if !found {
fmt.eprintfln("brolang %s: could not find build.bro in the current directory or any parent", command)
return "", false, false
}
return project_root, true, true
}
// run_build implements `brolang build [root]`: it reads build.bro and compiles
// the configured program package.
run_build :: proc(root: string) -> int {
project_root, owned, found := project_root_for_command(root, "build")
if !found {
return 2 return 2
} }
defer if owned {delete(project_root)}
cfg, ok := extract_build_config(&hir_module, &symbols) cfg, ok := load_build_config(project_root)
if !ok { if !ok {
return 2 return 2
} }
@@ -109,6 +112,48 @@ run_build :: proc(root: string) -> int {
return compile_package(program, output, cfg.link_arguments, target.DEFAULT, cfg.c_options, project_root) return compile_package(program, output, cfg.link_arguments, target.DEFAULT, cfg.c_options, project_root)
} }
run_tests :: proc(root: string) -> int {
project_root, owned, found := project_root_for_command(root, "test")
if !found {
return 2
}
defer if owned {delete(project_root)}
cfg, ok := load_build_config(project_root)
if !ok {
return 2
}
defer destroy_build_config(&cfg)
program := filepath.join({project_root, cfg.source_dir})
defer delete(program)
test_name := fmt.aprintf("%s-test", cfg.output_name)
defer delete(test_name)
output, output_ok := build_output_path(project_root, test_name)
if !output_ok {
return 2
}
defer delete(output)
status := compile_package(
program, output, cfg.link_arguments, target.DEFAULT, cfg.c_options, project_root, .Test,
)
if status != 0 {
return status
}
state, stdout, stderr, err := os2.process_exec(
os2.Process_Desc{command=[]string{output}},
context.allocator,
)
defer delete(stdout)
defer delete(stderr)
if len(stdout) > 0 {fmt.print(string(stdout))}
if len(stderr) > 0 {fmt.eprint(string(stderr))}
if err != nil {
fmt.eprintln("failed to run test executable:", err)
return 2
}
return 0 if state.exit_code == 0 else 1
}
valid_output_name :: proc(name: string) -> bool { valid_output_name :: proc(name: string) -> bool {
return len(name) > 0 && name != "." && name != ".." && return len(name) > 0 && name != "." && name != ".." &&
!strings.contains(name, "/") && !strings.contains(name, "\\") !strings.contains(name, "/") && !strings.contains(name, "\\")
+15 -4
View File
@@ -1386,9 +1386,20 @@ build_symbol_indexes :: proc(checker: ^Checker) {
} }
slice.sort_by(checker.global_index, global_index_less) slice.sort_by(checker.global_index, global_index_less)
checker.import_index = make([]Import_Index_Entry, len(checker.ast_module.imports), checker.allocator) import_count := 0
for import_item in checker.ast_module.imports {
if !import_item.test_only {
import_count += 1
}
}
checker.import_index = make([]Import_Index_Entry, import_count, checker.allocator)
import_index := 0
for import_item, id in checker.ast_module.imports { for import_item, id in checker.ast_module.imports {
checker.import_index[id] = Import_Index_Entry{scope=import_item.file, name=import_item.alias, id=ast.import_id(id)} if import_item.test_only {
continue
}
checker.import_index[import_index] = Import_Index_Entry{scope=import_item.file, name=import_item.alias, id=ast.import_id(id)}
import_index += 1
} }
slice.sort_by(checker.import_index, import_index_less) slice.sort_by(checker.import_index, import_index_less)
} }
@@ -12874,7 +12885,7 @@ check :: proc(
main_template := find_template(&checker, checker.main_symbol, 0) main_template := find_template(&checker, checker.main_symbol, 0)
main_declarations := 0 main_declarations := 0
for function in ast_module.functions { for function in ast_module.functions {
if function.pkg == 0 && function.name == checker.main_symbol { if !function.generated && function.pkg == 0 && function.name == checker.main_symbol {
main_declarations += 1 main_declarations += 1
} }
} }
@@ -12917,7 +12928,7 @@ check :: proc(
delete(states, allocator) delete(states, allocator)
propagate_problems(&checker) propagate_problems(&checker)
for import_item in ast_module.imports { for import_item in ast_module.imports {
if import_item.valid && !import_item.used { if !import_item.test_only && import_item.valid && !import_item.used {
source.addf_warning(diagnostics, import_item.span, "unused import '%s'", symbol_text(&checker, import_item.alias)) source.addf_warning(diagnostics, import_item.span, "unused import '%s'", symbol_text(&checker, import_item.alias))
} }
} }
+8
View File
@@ -1,5 +1,6 @@
package compiler package compiler
import "./ast"
import "./backend" import "./backend"
import "./cimport" import "./cimport"
import "./checker" import "./checker"
@@ -34,6 +35,7 @@ compile_package :: proc(
selected := target.DEFAULT, selected := target.DEFAULT,
c_options := cimport.Options{}, c_options := cimport.Options{},
project_root := "", project_root := "",
mode := ast.Compile_Mode.Executable,
) -> int { ) -> int {
sources := source.init_store() sources := source.init_store()
defer source.destroy_store(&sources) defer source.destroy_store(&sources)
@@ -77,12 +79,18 @@ compile_package :: proc(
c_options, c_options,
selected, selected,
project_root if len(project_root) > 0 else input_path, project_root if len(project_root) > 0 else input_path,
mode,
) )
if !loaded { if !loaded {
source.print_all(&diagnostics) source.print_all(&diagnostics)
fmt.eprintln("failed to load root package directory:", input_path) fmt.eprintln("failed to load root package directory:", input_path)
return 2 return 2
} }
effective_root := project_root if len(project_root) > 0 else input_path
if !prepare_tests(&ast_module, &sources, &diagnostics, &symbols, mode, effective_root) {
source.print_all(&diagnostics)
return 1
}
// Generated C trampolines (for `static inline` imports) must be compiled and // Generated C trampolines (for `static inline` imports) must be compiled and
// linked with the program. Write them out and add the source as a link input // linked with the program. Write them out and add the source as a link input
+1
View File
@@ -14,6 +14,7 @@ is_identifier_continue :: proc(value: byte) -> bool {
keyword_kind :: proc(text: string) -> token.Kind { keyword_kind :: proc(text: string) -> token.Kind {
switch text { switch text {
case "test": return .Keyword_Test
case "func": return .Keyword_Func case "func": return .Keyword_Func
case "c_func": return .Keyword_C_Func case "c_func": return .Keyword_C_Func
case "struct": return .Keyword_Struct case "struct": return .Keyword_Struct
+56 -22
View File
@@ -26,6 +26,7 @@ State :: struct {
c_options: cimport.Options, c_options: cimport.Options,
selected: target.Target, selected: target.Target,
project_root: string, project_root: string,
mode: ast.Compile_Mode,
record_identities: [dynamic]string, record_identities: [dynamic]string,
record_types: [dynamic]types.Type, record_types: [dynamic]types.Type,
root_failed: bool, root_failed: bool,
@@ -1130,7 +1131,42 @@ load_header :: proc(state: ^State, path: string, import_span: source.Span) -> as
return pkg_id return pkg_id
} }
load_package :: proc(state: ^State, path: string, import_span: source.Span, is_root := false) -> ast.Package_Id { resolve_package_imports :: proc(state: ^State, pkg_id: ast.Package_Id) {
if int(pkg_id) >= len(state.module.packages) {
return
}
canonical := state.module.packages[pkg_id].path
import_count := len(state.module.imports)
for import_id in 0..<import_count {
import_item := state.module.imports[import_id]
if import_item.pkg != pkg_id || import_item.target != ast.INVALID_PACKAGE ||
import_item.test_only && (state.mode != .Test || !state.module.packages[pkg_id].test) {
continue
}
if filepath.is_abs(import_item.path) {
state.module.imports[import_id].diagnostic = source.add(state.diagnostics, import_item.span, "absolute import paths are invalid")
state.module.imports[import_id].valid = false
state.module.imports[import_id].target = add_placeholder(state, import_item.path)
continue
}
target_path, target_ok := resolve_import_path(state, canonical, import_item.path)
target := load_header(state, target_path, import_item.span) if filepath.ext(import_item.path) == ".h" else
load_package(state, target_path, import_item.span, include_tests=import_item.test_only)
state.module.imports[import_id].target = target
if !target_ok || target == ast.INVALID_PACKAGE || !state.module.packages[target].available {
state.module.imports[import_id].valid = false
}
delete(target_path, state.allocator)
}
}
load_package :: proc(
state: ^State,
path: string,
import_span: source.Span,
is_root := false,
include_tests := false,
) -> ast.Package_Id {
canonical, ok := filepath.abs(path, state.allocator) canonical, ok := filepath.abs(path, state.allocator)
if !ok || !os.is_dir(path) { if !ok || !os.is_dir(path) {
if is_root { if is_root {
@@ -1152,6 +1188,10 @@ load_package :: proc(state: ^State, path: string, import_span: source.Span, is_r
return id return id
} }
if existing := find_package(state, canonical); existing != ast.INVALID_PACKAGE { if existing := find_package(state, canonical); existing != ast.INVALID_PACKAGE {
if include_tests && !state.module.packages[existing].test {
state.module.packages[existing].test = true
resolve_package_imports(state, existing)
}
delete(canonical, state.allocator) delete(canonical, state.allocator)
return existing return existing
} }
@@ -1161,6 +1201,7 @@ load_package :: proc(state: ^State, path: string, import_span: source.Span, is_r
path=canonical, path=canonical,
name=symbol.intern(state.symbols, filepath.base(canonical)), name=symbol.intern(state.symbols, filepath.base(canonical)),
available=true, available=true,
test=state.mode == .Test && is_root || include_tests,
}) })
files, files_ok := read_package_files(state, canonical) files, files_ok := read_package_files(state, canonical)
if !files_ok { if !files_ok {
@@ -1204,26 +1245,7 @@ load_package :: proc(state: ^State, path: string, import_span: source.Span, is_r
} }
os.file_info_slice_delete(files, state.allocator) os.file_info_slice_delete(files, state.allocator)
import_count := len(state.module.imports) resolve_package_imports(state, pkg_id)
for import_id in 0..<import_count {
import_item := state.module.imports[import_id]
if import_item.pkg != pkg_id || import_item.target != ast.INVALID_PACKAGE {
continue
}
if filepath.is_abs(import_item.path) {
state.module.imports[import_id].diagnostic = source.add(state.diagnostics, import_item.span, "absolute import paths are invalid")
state.module.imports[import_id].valid = false
state.module.imports[import_id].target = add_placeholder(state, import_item.path)
continue
}
target_path, target_ok := resolve_import_path(state, canonical, import_item.path)
target := load_header(state, target_path, import_item.span) if filepath.ext(import_item.path) == ".h" else load_package(state, target_path, import_item.span)
state.module.imports[import_id].target = target
if !target_ok || target == ast.INVALID_PACKAGE || !state.module.packages[target].available {
state.module.imports[import_id].valid = false
}
delete(target_path, state.allocator)
}
return pkg_id return pkg_id
} }
@@ -1248,6 +1270,9 @@ declaration_conflicts :: proc(module: ^ast.Module, pkg: ast.Package_Id, file: as
validate_imports :: proc(state: ^State) { validate_imports :: proc(state: ^State) {
for import_item, import_id in state.module.imports { for import_item, import_id in state.module.imports {
if import_item.test_only {
continue
}
if !symbol.is_valid(import_item.alias) && import_item.target != ast.INVALID_PACKAGE { if !symbol.is_valid(import_item.alias) && import_item.target != ast.INVALID_PACKAGE {
state.module.imports[import_id].alias = state.module.packages[import_item.target].name state.module.imports[import_id].alias = state.module.packages[import_item.target].name
} }
@@ -1287,7 +1312,7 @@ validate_imports :: proc(state: ^State) {
find_type_import :: proc(module: ^ast.Module, file: ast.File_Id, alias: symbol.Id) -> ast.Import_Id { find_type_import :: proc(module: ^ast.Module, file: ast.File_Id, alias: symbol.Id) -> ast.Import_Id {
for import_item, index in module.imports { for import_item, index in module.imports {
if import_item.file == file && import_item.alias == alias { if !import_item.test_only && import_item.file == file && import_item.alias == alias {
return ast.import_id(index) return ast.import_id(index)
} }
} }
@@ -1879,6 +1904,7 @@ load :: proc(
c_options := cimport.Options{}, c_options := cimport.Options{},
selected := target.DEFAULT, selected := target.DEFAULT,
project_root_path := "", project_root_path := "",
mode := ast.Compile_Mode.Executable,
) -> (ast.Module, bool) { ) -> (ast.Module, bool) {
module := ast.init_module(allocator) module := ast.init_module(allocator)
project_root_source := project_root_path if len(project_root_path) > 0 else root_path project_root_source := project_root_path if len(project_root_path) > 0 else root_path
@@ -1896,6 +1922,7 @@ load :: proc(
c_options=c_options, c_options=c_options,
selected=selected, selected=selected,
project_root=project_root, project_root=project_root,
mode=mode,
} }
state.record_identities.allocator = allocator state.record_identities.allocator = allocator
state.record_types.allocator = allocator state.record_types.allocator = allocator
@@ -1911,6 +1938,13 @@ load :: proc(
if root != ast.Package_Id(0) && root != ast.INVALID_PACKAGE { if root != ast.Package_Id(0) && root != ast.INVALID_PACKAGE {
state.root_failed = true state.root_failed = true
} }
if mode == .Test {
testing_path, testing_error := filepath.join({project_root, "std", "testing"}, allocator)
if testing_error == nil {
_ = load_package(&state, testing_path, source.Span{})
delete(testing_path, allocator)
}
}
validate_imports(&state) validate_imports(&state)
validate_declaration_aliases(&state) validate_declaration_aliases(&state)
resolve_enum_values(&state) resolve_enum_values(&state)
+41 -1
View File
@@ -3002,7 +3002,7 @@ decode_multiline_string :: proc(parser: ^Parser, tok: token.Token) -> string {
return fmt.aprintf("%s", strings.to_string(builder), allocator=parser.module.allocator) return fmt.aprintf("%s", strings.to_string(builder), allocator=parser.module.allocator)
} }
parse_import :: proc(parser: ^Parser, alias: token.Token, start: token.Token) { parse_import :: proc(parser: ^Parser, alias: token.Token, start: token.Token, test_only := false) {
skip_newlines(parser) skip_newlines(parser)
path_token := current(parser) path_token := current(parser)
if path_token.kind != .String { if path_token.kind != .String {
@@ -3018,6 +3018,7 @@ parse_import :: proc(parser: ^Parser, alias: token.Token, start: token.Token) {
file=parser.file, file=parser.file,
target=ast.INVALID_PACKAGE, target=ast.INVALID_PACKAGE,
valid=false, valid=false,
test_only=test_only,
diagnostic=source.INVALID_DIAGNOSTIC, diagnostic=source.INVALID_DIAGNOSTIC,
}) })
_ = finish_statement(parser) _ = finish_statement(parser)
@@ -3033,13 +3034,45 @@ parse_import :: proc(parser: ^Parser, alias: token.Token, start: token.Token) {
file=parser.file, file=parser.file,
target=ast.INVALID_PACKAGE, target=ast.INVALID_PACKAGE,
valid=true, valid=true,
test_only=test_only,
diagnostic=source.INVALID_DIAGNOSTIC, diagnostic=source.INVALID_DIAGNOSTIC,
}) })
_ = finish_statement(parser) _ = finish_statement(parser)
} }
parse_test :: proc(parser: ^Parser, name: token.Token) {
advance(parser) // consume 'test'
skip_newlines(parser)
if current(parser).kind != .Left_Brace {
source.add(parser.diagnostics, current(parser).span, "expected '{' after test name")
_ = finish_statement(parser)
return
}
body := parse_block(parser)
append(&parser.module.functions, ast.Function{
span=span_from(name.span, previous(parser).span),
name=name.symbol,
pkg=parser.pkg,
file=parser.file,
test=true,
has_body=true,
result=types.VOID,
body=body,
diagnostic=source.INVALID_DIAGNOSTIC,
})
}
parse_top_level :: proc(parser: ^Parser) { parse_top_level :: proc(parser: ^Parser) {
hide_token, file_hidden := allow(parser, .Keyword_Hide) hide_token, file_hidden := allow(parser, .Keyword_Hide)
if current(parser).kind == .Keyword_Test && peek(parser).kind == .Keyword_Import {
if file_hidden {
source.add(parser.diagnostics, hide_token.span, "test imports cannot use 'hide'")
}
start := advance(parser)
advance(parser) // consume 'import'
parse_import(parser, token.Token{}, start, test_only=true)
return
}
if current(parser).kind == .Keyword_Import { if current(parser).kind == .Keyword_Import {
if file_hidden { if file_hidden {
source.add(parser.diagnostics, hide_token.span, "imports are already file-local and cannot use 'hide'") source.add(parser.diagnostics, hide_token.span, "imports are already file-local and cannot use 'hide'")
@@ -3058,6 +3091,13 @@ parse_top_level :: proc(parser: ^Parser) {
return return
} }
name := advance(parser) name := advance(parser)
if current(parser).kind == .Keyword_Test {
if file_hidden {
source.add(parser.diagnostics, hide_token.span, "test declarations cannot use 'hide'")
}
parse_test(parser, name)
return
}
if current(parser).kind == .Colon_Colon { if current(parser).kind == .Colon_Colon {
saved := parser.cursor saved := parser.cursor
advance(parser) advance(parser)
+281
View File
@@ -0,0 +1,281 @@
package compiler
import "./ast"
import "./lexer"
import "./parser"
import "./source"
import "./symbol"
import "./types"
import "core:fmt"
import "core:path/filepath"
import "core:slice"
import "core:strings"
Test_Entry :: struct {
function: ast.Function_Id,
package_path: string,
source_path: string,
offset: source.Offset,
}
test_entry_less :: proc(a, b: Test_Entry) -> bool {
if a.package_path != b.package_path {
return a.package_path < b.package_path
}
if a.source_path != b.source_path {
return a.source_path < b.source_path
}
return a.offset < b.offset
}
testing_package :: proc(module: ^ast.Module, project_root: string) -> ast.Package_Id {
path, err := filepath.join({project_root, "std", "testing"}, module.allocator)
if err != nil {
return ast.INVALID_PACKAGE
}
defer delete(path, module.allocator)
canonical, ok := filepath.abs(path, module.allocator)
if !ok {
return ast.INVALID_PACKAGE
}
defer delete(canonical, module.allocator)
for pkg, index in module.packages {
if pkg.path == canonical {
return ast.package_id(index)
}
}
return ast.INVALID_PACKAGE
}
source_file_id :: proc(module: ^ast.Module, id: source.Source_Id) -> ast.File_Id {
for file, index in module.files {
if file.source == id {
return ast.file_id(index)
}
}
return ast.INVALID_FILE
}
append_ast_expr :: proc(module: ^ast.Module, expr: ast.Expr) -> ast.Expr_Id {
id := ast.expr_id(len(module.exprs))
append(&module.exprs, expr)
return id
}
location_expr :: proc(
module: ^ast.Module,
sources: ^source.Store,
symbols: ^symbol.Table,
span: source.Span,
qualifier: symbol.Id,
) -> ast.Expr_Id {
line, column := 1, 1
path := "<unknown>"
if int(span.file) < len(sources.items) {
file := &sources.items[span.file]
line, column = source.line_and_column(file, span.start)
path = file.path
}
string_index := u64(len(module.strings))
append(&module.strings, strings.clone(path, module.allocator))
file_expr := append_ast_expr(module, ast.Expr{
kind=.String, span=span, integer=string_index,
left=ast.INVALID_EXPR, right=ast.INVALID_EXPR,
diagnostic=source.INVALID_DIAGNOSTIC,
})
line_expr := append_ast_expr(module, ast.Expr{
kind=.Integer, span=span, integer=u64(line),
left=ast.INVALID_EXPR, right=ast.INVALID_EXPR,
diagnostic=source.INVALID_DIAGNOSTIC,
})
column_expr := append_ast_expr(module, ast.Expr{
kind=.Integer, span=span, integer=u64(column),
left=ast.INVALID_EXPR, right=ast.INVALID_EXPR,
diagnostic=source.INVALID_DIAGNOSTIC,
})
values := []ast.Expr_Id{file_expr, line_expr, column_expr}
names := []string{"file", "line", "column"}
fields := make([]ast.Expr_Id, 3, module.allocator)
for index in 0..<3 {
fields[index] = append_ast_expr(module, ast.Expr{
kind=.Keyed, span=span,
name=symbol.intern(symbols, names[index]),
left=values[index], right=ast.INVALID_EXPR,
diagnostic=source.INVALID_DIAGNOSTIC,
})
}
return append_ast_expr(module, ast.Expr{
kind=.Struct_Literal, span=span,
qualifier=qualifier,
name=symbol.intern(symbols, "SourceLocation"),
args=fields,
left=ast.INVALID_EXPR, right=ast.INVALID_EXPR,
diagnostic=source.INVALID_DIAGNOSTIC,
})
}
inject_assertion_locations :: proc(
module: ^ast.Module,
sources: ^source.Store,
symbols: ^symbol.Table,
testing_pkg: ast.Package_Id,
) {
expect := symbol.intern(symbols, "expect")
expect_equal := symbol.intern(symbols, "expect_equal")
original_count := len(module.exprs)
for index in 0..<original_count {
expr := &module.exprs[index]
if expr.kind != .Call || expr.intrinsic || !symbol.is_valid(expr.qualifier) ||
(expr.name != expect && expr.name != expect_equal) {
continue
}
file := source_file_id(module, expr.span.file)
matched := false
for import_item in module.imports {
if !import_item.test_only && import_item.file == file &&
import_item.alias == expr.qualifier && import_item.target == testing_pkg {
matched = true
break
}
}
if !matched {
continue
}
location := location_expr(module, sources, symbols, expr.span, expr.qualifier)
args := make([]ast.Expr_Id, len(expr.args)+1, module.allocator)
copy(args, expr.args)
args[len(expr.args)] = location
delete(expr.args, module.allocator)
expr.args = args
}
}
write_brolang_string :: proc(builder: ^strings.Builder, value: string) {
for byte_value in transmute([]byte)value {
if byte_value == '\\' || byte_value == '"' {
strings.write_byte(builder, '\\')
}
strings.write_byte(builder, byte_value)
}
}
append_runner :: proc(
module: ^ast.Module,
sources: ^source.Store,
diagnostics: ^source.Diagnostics,
symbols: ^symbol.Table,
testing_pkg: ast.Package_Id,
tests: []Test_Entry,
) {
builder := strings.builder_make(module.allocator)
defer strings.builder_destroy(&builder)
strings.write_string(&builder, "main func() i32 {\n\tfailed i32 = 0\n")
for entry, index in tests {
test_id := entry.function
test := module.functions[test_id]
pkg := module.packages[test.pkg]
alias := fmt.tprintf("__brolang_test_%d", index)
name := fmt.tprintf("%s.%s", filepath.base(pkg.path), symbol.resolve(symbols, test.name))
strings.write_string(&builder, "\tif (!__brolang_testing.run(\"")
write_brolang_string(&builder, name)
fmt.sbprintf(&builder, "\", %s.%s)) ", alias, symbol.resolve(symbols, test.name))
strings.write_string(&builder, "{\n\t\tfailed += 1\n\t}\n")
}
fmt.sbprintf(&builder, "\t__brolang_testing.summary(%d - failed, failed)\n", len(tests))
strings.write_string(&builder, "\tif (failed != 0) return 1\n\treturn 0\n}\n")
runner_text := strings.to_string(builder)
source_id := source.add_source(sources, "<brolang-test-runner>", runner_text)
file_id := ast.file_id(len(module.files))
append(&module.files, ast.File{source=source_id, pkg=0})
stream := lexer.lex(&sources.items[source_id], diagnostics, symbols, module.allocator)
defer delete(stream.items)
parser.parse_into(&stream, &sources.items[source_id], diagnostics, module, 0, file_id)
append(&module.imports, ast.Import{
alias=symbol.intern(symbols, "__brolang_testing"),
path=strings.clone("@std/testing", module.allocator),
pkg=0, file=file_id, target=testing_pkg,
valid=true, used=true,
diagnostic=source.INVALID_DIAGNOSTIC,
})
for entry, index in tests {
test_id := entry.function
test := module.functions[test_id]
append(&module.imports, ast.Import{
alias=symbol.intern(symbols, fmt.tprintf("__brolang_test_%d", index)),
path=strings.clone(module.packages[test.pkg].path, module.allocator),
pkg=0, file=file_id, target=test.pkg,
valid=true, used=true,
diagnostic=source.INVALID_DIAGNOSTIC,
})
}
}
prepare_tests :: proc(
module: ^ast.Module,
sources: ^source.Store,
diagnostics: ^source.Diagnostics,
symbols: ^symbol.Table,
mode: ast.Compile_Mode,
project_root: string,
) -> bool {
if mode == .Executable {
for &function in module.functions {
if !function.test {
continue
}
function.generated = true
for &import_item in module.imports {
if !import_item.test_only && import_item.file == function.file {
import_item.used = true
}
}
}
return true
}
testing_pkg := testing_package(module, project_root)
if testing_pkg == ast.INVALID_PACKAGE {
source.add(diagnostics, source.Span{}, "test builds require @std/testing")
return false
}
error_type := types.find_named(
&module.type_store,
u32(testing_pkg),
u32(symbol.intern(symbols, "Error")),
)
if !types.is_enum(error_type, &module.type_store) {
source.add(diagnostics, source.Span{}, "@std/testing must declare Error as an enum")
return false
}
tests: [dynamic]Test_Entry
tests.allocator = module.allocator
defer delete(tests)
main_name := symbol.intern(symbols, "main")
for &function, index in module.functions {
if function.test {
if int(function.pkg) < len(module.packages) && module.packages[function.pkg].test {
function.result = types.VOID
function.error = error_type
file := module.files[function.file]
append(&tests, Test_Entry{
function=ast.function_id(index),
package_path=module.packages[function.pkg].path,
source_path=sources.items[file.source].path,
offset=function.span.start,
})
} else {
function.generated = true
}
} else if function.pkg == 0 && function.name == main_name {
function.generated = true
}
}
slice.sort_by(tests[:], test_entry_less)
inject_assertion_locations(module, sources, symbols, testing_pkg)
append_runner(module, sources, diagnostics, symbols, testing_pkg, tests[:])
return true
}
+2 -1
View File
@@ -50,6 +50,7 @@ Kind :: enum u8 {
Right_Brace, Right_Brace,
Comma, Comma,
Pipe, Pipe,
Keyword_Test,
Keyword_Func, Keyword_Func,
Keyword_C_Func, Keyword_C_Func,
Keyword_Struct, Keyword_Struct,
@@ -118,7 +119,7 @@ Kind :: enum u8 {
} }
is_keyword :: proc(kind: Kind) -> bool { is_keyword :: proc(kind: Kind) -> bool {
return kind >= .Keyword_Func && kind <= .Keyword_C_Longdouble return kind >= .Keyword_Test && kind <= .Keyword_C_Longdouble
} }
Token :: struct { Token :: struct {
+98
View File
@@ -12778,3 +12778,101 @@ main func() void {
testing.expect(t, types.is_slice(nested_field, &hir_module.types)) testing.expect(t, types.is_slice(nested_field, &hir_module.types))
testing.expect_value(t, types.child_type(nested_field, &hir_module.types), types.INT) testing.expect_value(t, types.child_type(nested_field, &hir_module.types), types.INT)
} }
@(test)
parser_records_native_tests_and_test_imports :: proc(t: ^testing.T) {
text := `test import "../math"
addition test {
return
}
`
source_file := source.Source{path="tests.bro", text=text}
diagnostics := source.init_diagnostics(&source_file)
defer source.destroy_diagnostics(&diagnostics)
symbols := symbol.init_table()
defer symbol.destroy_table(&symbols)
stream := lexer.lex(&source_file, &diagnostics, &symbols)
defer delete(stream.items)
module := parser.parse(&stream, &source_file, &diagnostics)
defer ast.destroy_module(&module)
testing.expect_value(t, len(diagnostics.items), 0)
testing.expect_value(t, len(module.imports), 1)
testing.expect(t, module.imports[0].test_only)
testing.expect_value(t, module.imports[0].alias, symbol.INVALID)
testing.expect_value(t, len(module.functions), 1)
testing.expect(t, module.functions[0].test)
testing.expect_value(t, symbol.resolve(&symbols, module.functions[0].name), "addition")
}
@(test)
native_test_framework_discovers_reports_and_preserves_main :: proc(t: ^testing.T) {
directory := "/tmp/brolang-test-native-framework"
root := "/tmp/brolang-test-native-framework/root"
dependency := "/tmp/brolang-test-native-framework/dependency"
root_path := "/tmp/brolang-test-native-framework/root/main.bro"
dependency_path := "/tmp/brolang-test-native-framework/dependency/math.bro"
test_output := "/tmp/brolang-test-native-framework-tests"
app_output := "/tmp/brolang-test-native-framework-app"
_ = os2.remove_all(directory)
defer _ = os2.remove_all(directory)
defer _ = os.remove(test_output)
defer _ = os.remove(app_output)
testing.expect(t, os2.make_directory_all(root) == nil)
testing.expect(t, os2.make_directory_all(dependency) == nil)
root_text := `testing :: import "@std/testing"
test import "../dependency"
main func() i32 { return 77 }
root_passes test {
try testing.expect(true)
}
root_fails test {
try testing.expect_equal(42, 41)
}
root_continues test {
try testing.expect(true)
}
`
dependency_text := `testing :: import "@std/testing"
dependency_passes test {
try testing.expect(true)
}
`
testing.expect(t, os.write_entire_file(root_path, transmute([]byte)root_text))
testing.expect(t, os.write_entire_file(dependency_path, transmute([]byte)dependency_text))
app_status := compiler_core.compile_package(
root, app_output, nil, target.DEFAULT, cimport.Options{}, ".",
)
testing.expect_value(t, app_status, 0)
app_state := run_executable(app_output)
testing.expect_value(t, app_state.exit_code, 77)
test_status := compiler_core.compile_package(
root, test_output, nil, target.DEFAULT, cimport.Options{}, ".", .Test,
)
testing.expect_value(t, test_status, 0)
state, stdout, stderr, _ := os2.process_exec(
os2.Process_Desc{command=[]string{test_output}},
context.allocator,
)
defer delete(stdout)
defer delete(stderr)
output := string(stderr)
testing.expect_value(t, state.exit_code, 1)
testing.expect(t, strings.contains(output, "PASS root.root_passes"))
testing.expect(t, strings.contains(output, "FAIL root.root_fails"))
testing.expect(t, strings.contains(output, "expected 42, found 41"))
testing.expect(t, strings.contains(output, "PASS root.root_continues"))
testing.expect(t, strings.contains(output, "PASS dependency.dependency_passes"))
testing.expect(t, strings.contains(output, root_path))
testing.expect(t, strings.contains(output, "3 passed, 1 failed"))
}
+1 -1
View File
@@ -9,5 +9,5 @@ languages = ["languages/brolang"]
[grammars.brolang] [grammars.brolang]
repository = "file:///Users/valdemar/Developer/Personal/Languages/brolang" repository = "file:///Users/valdemar/Developer/Personal/Languages/brolang"
rev = "03f5445509575fb72226e8b4bbf230d7905b7b8e" rev = "2e8234c6a9a12326f230ea16e901ac41a1b48c99"
path = "tree-sitter-brolang" path = "tree-sitter-brolang"
+2
View File
@@ -35,6 +35,7 @@
(type_declaration name: (identifier) @type) (type_declaration name: (identifier) @type)
(function_declaration name: (identifier) @function) (function_declaration name: (identifier) @function)
(test_declaration name: (identifier) @function)
(parameter name: (identifier) @variable.parameter) (parameter name: (identifier) @variable.parameter)
(intrinsic_call_expression function: (identifier) @function.builtin) (intrinsic_call_expression function: (identifier) @function.builtin)
@@ -53,6 +54,7 @@
[ [
"func" "func"
"test"
"c_func" "c_func"
"struct" "struct"
"c_struct" "c_struct"
+15
View File
@@ -103,6 +103,9 @@ print_usage :: proc() {
fmt.eprintln( fmt.eprintln(
" brolang build [root] (reads root/build.bro; writes root/build/name)", " brolang build [root] (reads root/build.bro; writes root/build/name)",
) )
fmt.eprintln(
" brolang test [root] (reads root/build.bro; writes and runs root/build/name-test)",
)
fmt.eprintln( fmt.eprintln(
" brolang new <project-path>", " brolang new <project-path>",
) )
@@ -122,6 +125,10 @@ is_build_command :: proc(arg: string) -> bool {
return arg == "build" return arg == "build"
} }
is_test_command :: proc(arg: string) -> bool {
return arg == "test"
}
is_new_command :: proc(arg: string) -> bool { is_new_command :: proc(arg: string) -> bool {
return arg == "new" return arg == "new"
} }
@@ -609,6 +616,14 @@ main :: proc() {
root := os2.args[2] if len(os2.args) == 3 else "" root := os2.args[2] if len(os2.args) == 3 else ""
os2.exit(compiler.run_build(root)) os2.exit(compiler.run_build(root))
} }
if len(os2.args) >= 2 && is_test_command(os2.args[1]) {
if len(os2.args) > 3 {
print_usage()
os2.exit(2)
}
root := os2.args[2] if len(os2.args) == 3 else ""
os2.exit(compiler.run_tests(root))
}
if len(os2.args) >= 2 && is_new_command(os2.args[1]) { if len(os2.args) >= 2 && is_new_command(os2.args[1]) {
if len(os2.args) != 3 { if len(os2.args) != 3 {
print_usage() print_usage()
+39
View File
@@ -0,0 +1,39 @@
debug :: import "@std/debug"
Error :: enum {
expectation_failed
}
SourceLocation :: struct {
file []u8
line usize
column usize
}
expect func(condition bool, location SourceLocation) void ! Error {
if (!condition) {
debug.print("{s}:{d}:{d}: expectation failed\n", {location.file, location.line, location.column})
return .expectation_failed
}
}
expect_equal func($T type, expected, actual T, location SourceLocation) void ! Error {
if (expected != actual) {
debug.print("{s}:{d}:{d}: expected {}, found {}\n", {location.file, location.line, location.column, expected, actual})
return .expectation_failed
}
}
run func(name []u8, callback *func() void ! Error) bool {
debug.print("RUN {s}\n", {name,})
callback() catch |_| {
debug.print("FAIL {s}\n", {name,})
return false
}
debug.print("PASS {s}\n", {name,})
return true
}
summary func(passed, failed i32) void {
debug.print("{d} passed, {d} failed\n", {passed, failed})
}
+16
View File
@@ -40,7 +40,9 @@ module.exports = grammar({
_top_level_declaration: $ => choice( _top_level_declaration: $ => choice(
$.import_declaration, $.import_declaration,
$.test_import_declaration,
$.function_declaration, $.function_declaration,
$.test_declaration,
$.type_declaration, $.type_declaration,
$.global_constant_declaration, $.global_constant_declaration,
$.global_variable_declaration, $.global_variable_declaration,
@@ -53,6 +55,20 @@ module.exports = grammar({
field('path', $.string), field('path', $.string),
), ),
test_import_declaration: $ => seq(
'test',
'import',
repeat($._newline),
field('path', $.string),
),
test_declaration: $ => seq(
field('name', $.identifier),
'test',
repeat($._newline),
field('body', $.block),
),
function_declaration: $ => seq( function_declaration: $ => seq(
optional('hide'), optional('hide'),
field('name', $.identifier), field('name', $.identifier),
@@ -35,6 +35,7 @@
(type_declaration name: (identifier) @type) (type_declaration name: (identifier) @type)
(function_declaration name: (identifier) @function) (function_declaration name: (identifier) @function)
(test_declaration name: (identifier) @function)
(parameter name: (identifier) @variable.parameter) (parameter name: (identifier) @variable.parameter)
(intrinsic_call_expression function: (identifier) @function.builtin) (intrinsic_call_expression function: (identifier) @function.builtin)
@@ -53,6 +54,7 @@
[ [
"func" "func"
"test"
"c_func" "c_func"
"struct" "struct"
"c_struct" "c_struct"
+68
View File
@@ -26,10 +26,18 @@
"type": "SYMBOL", "type": "SYMBOL",
"name": "import_declaration" "name": "import_declaration"
}, },
{
"type": "SYMBOL",
"name": "test_import_declaration"
},
{ {
"type": "SYMBOL", "type": "SYMBOL",
"name": "function_declaration" "name": "function_declaration"
}, },
{
"type": "SYMBOL",
"name": "test_declaration"
},
{ {
"type": "SYMBOL", "type": "SYMBOL",
"name": "type_declaration" "name": "type_declaration"
@@ -100,6 +108,66 @@
} }
] ]
}, },
"test_import_declaration": {
"type": "SEQ",
"members": [
{
"type": "STRING",
"value": "test"
},
{
"type": "STRING",
"value": "import"
},
{
"type": "REPEAT",
"content": {
"type": "SYMBOL",
"name": "_newline"
}
},
{
"type": "FIELD",
"name": "path",
"content": {
"type": "SYMBOL",
"name": "string"
}
}
]
},
"test_declaration": {
"type": "SEQ",
"members": [
{
"type": "FIELD",
"name": "name",
"content": {
"type": "SYMBOL",
"name": "identifier"
}
},
{
"type": "STRING",
"value": "test"
},
{
"type": "REPEAT",
"content": {
"type": "SYMBOL",
"name": "_newline"
}
},
{
"type": "FIELD",
"name": "body",
"content": {
"type": "SYMBOL",
"name": "block"
}
}
]
},
"function_declaration": { "function_declaration": {
"type": "SEQ", "type": "SEQ",
"members": [ "members": [
+54
View File
@@ -1852,6 +1852,14 @@
"type": "import_declaration", "type": "import_declaration",
"named": true "named": true
}, },
{
"type": "test_declaration",
"named": true
},
{
"type": "test_import_declaration",
"named": true
},
{ {
"type": "type_declaration", "type": "type_declaration",
"named": true "named": true
@@ -2000,6 +2008,48 @@
] ]
} }
}, },
{
"type": "test_declaration",
"named": true,
"fields": {
"body": {
"multiple": false,
"required": true,
"types": [
{
"type": "block",
"named": true
}
]
},
"name": {
"multiple": false,
"required": true,
"types": [
{
"type": "identifier",
"named": true
}
]
}
}
},
{
"type": "test_import_declaration",
"named": true,
"fields": {
"path": {
"multiple": false,
"required": true,
"types": [
{
"type": "string",
"named": true
}
]
}
}
},
{ {
"type": "tuple_literal", "type": "tuple_literal",
"named": true, "named": true,
@@ -2737,6 +2787,10 @@
"type": "struct", "type": "struct",
"named": false "named": false
}, },
{
"type": "test",
"named": false
},
{ {
"type": "true", "type": "true",
"named": false "named": false
+140359 -139633
View File
File diff suppressed because it is too large Load Diff
+25 -2
View File
@@ -364,7 +364,30 @@ main func() void {
(expression (expression
(sink)) (sink))
(expression (expression
(identifier)))))))))) (identifier))))))))))
==================
Native tests
==================
math :: import "../math"
test import "../math"
addition test {}
---
(source_file
(import_declaration
(identifier)
(string
(string_content)))
(test_import_declaration
(string
(string_content)))
(test_declaration
(identifier)
(block)))
================== ==================
Expanded match arms Expanded match arms
@@ -373,7 +396,7 @@ Expanded match arms
Kind :: enum { one, two } Kind :: enum { one, two }
Value :: union(enum) { number i32, empty void } Value :: union(enum) { number i32, empty void }
test func(kind Kind, value Value) void { visit func(kind Kind, value Value) void {
match kind { match kind {
.one: {} .one: {}
expand |tag|: _ = tag expand |tag|: _ = tag