native test framework
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user