1. New build system
All checks were successful
build-dsl-smoke / Build judge (push) Successful in 12s
build-dsl-smoke / debug / clang / linux (push) Successful in 6s
build-dsl-smoke / debug / gcc / linux (push) Successful in 8s
build-dsl-smoke / release / clang / linux (push) Successful in 8s
build-dsl-smoke / release / gcc / linux (push) Successful in 6s
build-dsl-smoke / sanitized / clang / linux (push) Successful in 8s
build-dsl-smoke / sanitized / gcc / linux (push) Successful in 7s
build-dsl-smoke / debug / clang / windows (push) Successful in 13s
build-dsl-smoke / debug-valgrind / gcc / linux (push) Successful in 14s
build-dsl-smoke / release / clang / windows (push) Successful in 16s
build-dsl-smoke / debug / msvc / windows (push) Successful in 18s
build-dsl-smoke / release / msvc / windows (push) Successful in 17s
build-dsl-smoke / SUMMARY (push) Successful in 4s
Release / Build & publish (push) Successful in 48s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-04-12 07:59:38 +00:00
parent 358e3146bc
commit 7ec3a43c7a
47 changed files with 14124 additions and 209 deletions

198
runner/compiler.go Normal file
View File

@@ -0,0 +1,198 @@
package runner
import (
"fmt"
"runtime"
"sort"
"strings"
"github.com/Mond1c/judge/dsl"
)
type CompilerClass int
const (
CompilerUnknown CompilerClass = iota
CompilerGNU
CompilerMSVC
)
type Toolchain struct {
Class CompilerClass
Binary string
Name string
}
func ResolveToolchain(ccEnv string) Toolchain {
if ccEnv == "" {
ccEnv = defaultCC()
}
lower := strings.ToLower(ccEnv)
switch lower {
case "gcc", "g++":
return Toolchain{Class: CompilerGNU, Binary: ccEnv, Name: "gcc"}
case "clang", "clang++":
return Toolchain{Class: CompilerGNU, Binary: ccEnv, Name: "clang"}
case "cl", "cl.exe", "msvc":
return Toolchain{Class: CompilerMSVC, Binary: "cl", Name: "msvc"}
case "clang-cl", "clang-cl.exe":
return Toolchain{Class: CompilerMSVC, Binary: "clang-cl", Name: "clang-cl"}
case "cc":
return Toolchain{Class: CompilerGNU, Binary: "cc", Name: "cc"}
default:
return Toolchain{Class: CompilerGNU, Binary: ccEnv, Name: lower}
}
}
func ResolveToolchainSpec(spec *dsl.ToolchainSpec) Toolchain {
inferred := ResolveToolchain(spec.Name)
binary := spec.Binary
if binary == "" {
binary = inferred.Binary
}
var class CompilerClass
switch spec.Class {
case "gnu":
class = CompilerGNU
case "msvc":
class = CompilerMSVC
default:
class = inferred.Class
}
return Toolchain{Class: class, Binary: binary, Name: spec.Name}
}
func defaultCC() string {
if runtime.GOOS == "windows" {
return "cl"
}
return "cc"
}
func Compile(cfg dsl.BuildConfig, tc Toolchain, outputPath string) ([]string, error) {
switch tc.Class {
case CompilerGNU:
return compileGNU(cfg, tc, outputPath), nil
case CompilerMSVC:
return compileMSVC(cfg, tc, outputPath), nil
default:
return nil, fmt.Errorf("unknown compiler class for toolchain %q", tc.Name)
}
}
func compileGNU(cfg dsl.BuildConfig, tc Toolchain, outputPath string) []string {
argv := []string{tc.Binary}
if cfg.Standard != "" {
argv = append(argv, "-std="+cfg.Standard)
}
switch cfg.Profile {
case dsl.ProfileRelease:
argv = append(argv, "-O2")
case dsl.ProfileDebug:
argv = append(argv, "-O0", "-g")
case dsl.ProfileSanitized:
argv = append(argv, "-O1", "-g", "-fno-omit-frame-pointer")
}
switch cfg.Warnings {
case dsl.WarningsStrict:
argv = append(argv, "-Wall", "-Wextra")
case dsl.WarningsPedantic:
argv = append(argv, "-Wall", "-Wextra", "-Wpedantic")
}
if len(cfg.Sanitize) > 0 {
argv = append(argv, "-fsanitize="+strings.Join(cfg.Sanitize, ","))
}
for _, inc := range cfg.Includes {
argv = append(argv, "-I"+inc)
}
for _, k := range sortedKeys(cfg.Defines) {
v := cfg.Defines[k]
if v == "" {
argv = append(argv, "-D"+k)
} else {
argv = append(argv, fmt.Sprintf("-D%s=%s", k, v))
}
}
argv = append(argv, cfg.Extra...)
argv = append(argv, cfg.Sources...)
argv = append(argv, "-o", outputPath)
for _, lib := range cfg.Link {
argv = append(argv, "-l"+lib)
}
return argv
}
func compileMSVC(cfg dsl.BuildConfig, tc Toolchain, outputPath string) []string {
argv := []string{tc.Binary, "/nologo"}
if cfg.Standard != "" {
argv = append(argv, "/std:"+cfg.Standard)
}
switch cfg.Profile {
case dsl.ProfileRelease:
argv = append(argv, "/O2")
case dsl.ProfileDebug:
argv = append(argv, "/Od", "/Zi")
case dsl.ProfileSanitized:
argv = append(argv, "/Od", "/Zi", "/fsanitize=address")
}
switch cfg.Warnings {
case dsl.WarningsStrict:
argv = append(argv, "/W4")
case dsl.WarningsPedantic:
argv = append(argv, "/W4", "/permissive-")
}
if containsString(cfg.Sanitize, "address") && cfg.Profile != dsl.ProfileSanitized {
argv = append(argv, "/fsanitize=address")
}
for _, inc := range cfg.Includes {
argv = append(argv, "/I"+inc)
}
for _, k := range sortedKeys(cfg.Defines) {
v := cfg.Defines[k]
if v == "" {
argv = append(argv, "/D"+k)
} else {
argv = append(argv, fmt.Sprintf("/D%s=%s", k, v))
}
}
argv = append(argv, cfg.Extra...)
argv = append(argv, cfg.Sources...)
argv = append(argv, "/Fe:"+outputPath)
return argv
}
func sortedKeys(m map[string]string) []string {
if len(m) == 0 {
return nil
}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func containsString(xs []string, x string) bool {
for _, v := range xs {
if v == x {
return true
}
}
return false
}

293
runner/compiler_test.go Normal file
View File

@@ -0,0 +1,293 @@
package runner
import (
"reflect"
"strings"
"testing"
"github.com/Mond1c/judge/dsl"
)
func TestResolveToolchainKnown(t *testing.T) {
cases := []struct {
in string
wantClass CompilerClass
wantName string
}{
{"gcc", CompilerGNU, "gcc"},
{"g++", CompilerGNU, "gcc"},
{"clang", CompilerGNU, "clang"},
{"clang++", CompilerGNU, "clang"},
{"cl", CompilerMSVC, "msvc"},
{"cl.exe", CompilerMSVC, "msvc"},
{"msvc", CompilerMSVC, "msvc"},
{"clang-cl", CompilerMSVC, "clang-cl"},
{"cc", CompilerGNU, "cc"},
}
for _, c := range cases {
t.Run(c.in, func(t *testing.T) {
tc := ResolveToolchain(c.in)
if tc.Class != c.wantClass {
t.Errorf("class: got %v, want %v", tc.Class, c.wantClass)
}
if tc.Name != c.wantName {
t.Errorf("name: got %q, want %q", tc.Name, c.wantName)
}
})
}
}
func TestResolveToolchainSpec(t *testing.T) {
cases := []struct {
name string
spec dsl.ToolchainSpec
wantClass CompilerClass
wantBin string
wantName string
}{
{
"gcc inferred",
dsl.ToolchainSpec{Name: "gcc", Platforms: []string{"linux"}},
CompilerGNU, "gcc", "gcc",
},
{
"msvc inferred",
dsl.ToolchainSpec{Name: "msvc", Platforms: []string{"windows"}},
CompilerMSVC, "cl", "msvc",
},
{
"nvcc with explicit class",
dsl.ToolchainSpec{Name: "nvcc", Platforms: []string{"linux"}, Class: "gnu"},
CompilerGNU, "nvcc", "nvcc",
},
{
"custom binary override",
dsl.ToolchainSpec{Name: "clang", Platforms: []string{"linux", "windows"}, Binary: "clang-17"},
CompilerGNU, "clang-17", "clang",
},
{
"unknown name, explicit class",
dsl.ToolchainSpec{Name: "hipcc", Platforms: []string{"linux"}, Class: "gnu"},
CompilerGNU, "hipcc", "hipcc",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := ResolveToolchainSpec(&c.spec)
if got.Class != c.wantClass {
t.Errorf("class: got %v, want %v", got.Class, c.wantClass)
}
if got.Binary != c.wantBin {
t.Errorf("binary: got %q, want %q", got.Binary, c.wantBin)
}
if got.Name != c.wantName {
t.Errorf("name: got %q, want %q", got.Name, c.wantName)
}
})
}
}
func TestResolveToolchainUnknownFallsBackToGNU(t *testing.T) {
tc := ResolveToolchain("gcc-13")
if tc.Class != CompilerGNU {
t.Errorf("unknown compiler should fall back to GNU, got %v", tc.Class)
}
if tc.Binary != "gcc-13" {
t.Errorf("binary should preserve the original string, got %q", tc.Binary)
}
if tc.Name != "gcc-13" {
t.Errorf("name should be the lowercased original, got %q", tc.Name)
}
}
func TestCompileGNURelease(t *testing.T) {
cfg := dsl.BuildConfig{
Language: "c",
Standard: "c11",
Sources: []string{"solution.c"},
Profile: dsl.ProfileRelease,
Warnings: dsl.WarningsStrict,
}
tc := Toolchain{Class: CompilerGNU, Binary: "gcc", Name: "gcc"}
argv, err := Compile(cfg, tc, "solution")
if err != nil {
t.Fatal(err)
}
want := []string{"gcc", "-std=c11", "-O2", "-Wall", "-Wextra", "solution.c", "-o", "solution"}
if !reflect.DeepEqual(argv, want) {
t.Errorf("argv =\n %v\nwant\n %v", argv, want)
}
}
func TestCompileGNUDebug(t *testing.T) {
cfg := dsl.BuildConfig{
Standard: "c11",
Sources: []string{"a.c", "b.c"},
Profile: dsl.ProfileDebug,
}
tc := Toolchain{Class: CompilerGNU, Binary: "gcc"}
argv, _ := Compile(cfg, tc, "out")
if !containsSubsequence(argv, []string{"-O0", "-g"}) {
t.Errorf("debug flags missing: %v", argv)
}
if !containsSubsequence(argv, []string{"a.c", "b.c", "-o", "out"}) {
t.Errorf("sources and output order wrong: %v", argv)
}
}
func TestCompileGNUSanitized(t *testing.T) {
cfg := dsl.BuildConfig{
Standard: "c11",
Sources: []string{"s.c"},
Profile: dsl.ProfileSanitized,
Sanitize: []string{"address", "undefined"},
}
tc := Toolchain{Class: CompilerGNU, Binary: "clang"}
argv, _ := Compile(cfg, tc, "s")
joined := strings.Join(argv, " ")
if !strings.Contains(joined, "-fsanitize=address,undefined") {
t.Errorf("sanitize flag missing: %v", argv)
}
if !strings.Contains(joined, "-O1") {
t.Errorf("-O1 missing for sanitized profile: %v", argv)
}
}
func TestCompileGNUIncludesAndDefinesAndLink(t *testing.T) {
cfg := dsl.BuildConfig{
Sources: []string{"m.c"},
Includes: []string{"include", "vendor/inc"},
Defines: map[string]string{"FOO": "1", "BAR": ""},
Link: []string{"m", "pthread"},
Extra: []string{"-fno-strict-aliasing"},
}
tc := Toolchain{Class: CompilerGNU, Binary: "gcc"}
argv, _ := Compile(cfg, tc, "out")
joined := strings.Join(argv, " ")
for _, want := range []string{"-Iinclude", "-Ivendor/inc", "-DFOO=1", "-DBAR", "-lm", "-lpthread", "-fno-strict-aliasing"} {
if !strings.Contains(joined, want) {
t.Errorf("missing %q in %v", want, argv)
}
}
oIdx := indexOf(argv, "-o")
lmIdx := indexOf(argv, "-lm")
if oIdx == -1 || lmIdx == -1 || lmIdx < oIdx {
t.Errorf("-lm must come after -o: %v", argv)
}
}
func TestCompileGNUDefinesOrderDeterministic(t *testing.T) {
cfg := dsl.BuildConfig{
Sources: []string{"s.c"},
Defines: map[string]string{"Z": "1", "A": "2", "M": "3"},
}
tc := Toolchain{Class: CompilerGNU, Binary: "gcc"}
argv1, _ := Compile(cfg, tc, "s")
for i := 0; i < 20; i++ {
argv2, _ := Compile(cfg, tc, "s")
if !reflect.DeepEqual(argv1, argv2) {
t.Fatalf("defines order not deterministic:\n %v\n %v", argv1, argv2)
}
}
}
func TestCompileMSVCRelease(t *testing.T) {
cfg := dsl.BuildConfig{
Standard: "c11",
Sources: []string{"solution.c"},
Profile: dsl.ProfileRelease,
Warnings: dsl.WarningsStrict,
}
tc := Toolchain{Class: CompilerMSVC, Binary: "cl", Name: "msvc"}
argv, _ := Compile(cfg, tc, "solution.exe")
want := []string{"cl", "/nologo", "/std:c11", "/O2", "/W4", "solution.c", "/Fe:solution.exe"}
if !reflect.DeepEqual(argv, want) {
t.Errorf("argv =\n %v\nwant\n %v", argv, want)
}
}
func TestCompileMSVCDebug(t *testing.T) {
cfg := dsl.BuildConfig{
Sources: []string{"s.c"},
Profile: dsl.ProfileDebug,
}
tc := Toolchain{Class: CompilerMSVC, Binary: "cl"}
argv, _ := Compile(cfg, tc, "s.exe")
joined := strings.Join(argv, " ")
for _, want := range []string{"/Od", "/Zi"} {
if !strings.Contains(joined, want) {
t.Errorf("missing %q in %v", want, argv)
}
}
}
func TestCompileMSVCSanitizedAddressOnly(t *testing.T) {
cfg := dsl.BuildConfig{
Sources: []string{"s.c"},
Profile: dsl.ProfileSanitized,
Sanitize: []string{"address", "undefined", "thread"},
}
tc := Toolchain{Class: CompilerMSVC, Binary: "cl"}
argv, _ := Compile(cfg, tc, "s.exe")
joined := strings.Join(argv, " ")
if !strings.Contains(joined, "/fsanitize=address") {
t.Errorf("msvc should emit /fsanitize=address for sanitized profile: %v", argv)
}
if strings.Contains(joined, "undefined") || strings.Contains(joined, "thread") {
t.Errorf("msvc should drop unsupported sanitizers, got: %v", argv)
}
}
func TestCompileMSVCIncludesAndDefines(t *testing.T) {
cfg := dsl.BuildConfig{
Sources: []string{"m.c"},
Includes: []string{"include"},
Defines: map[string]string{"FOO": "1", "BAR": ""},
}
tc := Toolchain{Class: CompilerMSVC, Binary: "cl"}
argv, _ := Compile(cfg, tc, "m.exe")
joined := strings.Join(argv, " ")
for _, want := range []string{"/Iinclude", "/DFOO=1", "/DBAR"} {
if !strings.Contains(joined, want) {
t.Errorf("missing %q in %v", want, argv)
}
}
}
func TestCompileUnknownClassErrors(t *testing.T) {
cfg := dsl.BuildConfig{Sources: []string{"s.c"}}
tc := Toolchain{Class: CompilerUnknown, Binary: "weird"}
if _, err := Compile(cfg, tc, "s"); err == nil {
t.Error("expected error for unknown compiler class")
}
}
func containsSubsequence(haystack, needle []string) bool {
if len(needle) == 0 {
return true
}
for i := 0; i+len(needle) <= len(haystack); i++ {
match := true
for j := range needle {
if haystack[i+j] != needle[j] {
match = false
break
}
}
if match {
return true
}
}
return false
}
func indexOf(xs []string, x string) int {
for i, v := range xs {
if v == x {
return i
}
}
return -1
}

View File

@@ -16,47 +16,85 @@ func expandPattern(pattern *dsl.Pattern) ([]*dsl.Test, error) {
return expandGlobPattern(pattern)
}
type patternCase struct {
name string
inputPath string
outputPath string
dir string
}
func expandGlobPattern(pattern *dsl.Pattern) ([]*dsl.Test, error) {
inputFiles, err := filepath.Glob(pattern.InputGlob)
if err != nil {
return nil, fmt.Errorf("invalid input glob %q: %w", pattern.InputGlob, err)
inputIsGlob := strings.Contains(pattern.InputGlob, "*")
outputIsGlob := strings.Contains(pattern.OutputGlob, "*")
if pattern.InputGlob == "" && pattern.OutputGlob == "" {
return nil, fmt.Errorf("pattern needs at least one of input/output/dirs")
}
if len(inputFiles) == 0 {
return nil, fmt.Errorf("no files matched input glob %q", pattern.InputGlob)
if !inputIsGlob && !outputIsGlob {
return nil, fmt.Errorf("pattern needs at least one glob field (input or output must contain *)")
}
inputPrefix, inputSuffix := splitGlob(pattern.InputGlob)
outputPrefix, outputSuffix := splitGlob(pattern.OutputGlob)
var cases []patternCase
var tests []*dsl.Test
for _, inputPath := range inputFiles {
wildcard := extractWildcard(inputPath, inputPrefix, inputSuffix)
outputPath := outputPrefix + wildcard + outputSuffix
inputContent, err := os.ReadFile(inputPath)
switch {
case inputIsGlob && outputIsGlob:
inputFiles, err := filepath.Glob(pattern.InputGlob)
if err != nil {
return nil, fmt.Errorf("read input %q: %w", inputPath, err)
return nil, fmt.Errorf("invalid input glob %q: %w", pattern.InputGlob, err)
}
outputContent, err := os.ReadFile(outputPath)
if err != nil {
return nil, fmt.Errorf("read output %q: %w", outputPath, err)
if len(inputFiles) == 0 {
return nil, fmt.Errorf("no files matched input glob %q", pattern.InputGlob)
}
inputPrefix, inputSuffix := splitGlob(pattern.InputGlob)
outputPrefix, outputSuffix := splitGlob(pattern.OutputGlob)
for _, inputPath := range inputFiles {
wildcard := extractWildcard(inputPath, inputPrefix, inputSuffix)
outputPath := outputPrefix + wildcard + outputSuffix
cases = append(cases, patternCase{
name: wildcard,
inputPath: inputPath,
outputPath: outputPath,
})
}
name := fmt.Sprintf("pattern:%s", wildcard)
stdin := string(inputContent)
expected := string(outputContent)
case inputIsGlob && !outputIsGlob:
inputFiles, err := filepath.Glob(pattern.InputGlob)
if err != nil {
return nil, fmt.Errorf("invalid input glob %q: %w", pattern.InputGlob, err)
}
if len(inputFiles) == 0 {
return nil, fmt.Errorf("no files matched input glob %q", pattern.InputGlob)
}
inputPrefix, inputSuffix := splitGlob(pattern.InputGlob)
for _, inputPath := range inputFiles {
wildcard := extractWildcard(inputPath, inputPrefix, inputSuffix)
cases = append(cases, patternCase{
name: wildcard,
inputPath: inputPath,
outputPath: pattern.OutputGlob,
})
}
tests = append(tests, &dsl.Test{
Name: name,
Stdin: &stdin,
Env: map[string]string{},
InFiles: map[string]string{},
OutFiles: map[string]string{},
Stdout: dsl.ExactMatcher{Value: expected},
Stderr: dsl.NoMatcher{},
})
case !inputIsGlob && outputIsGlob:
outputFiles, err := filepath.Glob(pattern.OutputGlob)
if err != nil {
return nil, fmt.Errorf("invalid output glob %q: %w", pattern.OutputGlob, err)
}
if len(outputFiles) == 0 {
return nil, fmt.Errorf("no files matched output glob %q", pattern.OutputGlob)
}
outputPrefix, outputSuffix := splitGlob(pattern.OutputGlob)
for _, outputPath := range outputFiles {
wildcard := extractWildcard(outputPath, outputPrefix, outputSuffix)
cases = append(cases, patternCase{
name: wildcard,
inputPath: pattern.InputGlob,
outputPath: outputPath,
})
}
}
return tests, nil
return buildTests(cases, pattern.Args)
}
func expandDirPattern(pattern *dsl.Pattern) ([]*dsl.Test, error) {
@@ -68,42 +106,96 @@ func expandDirPattern(pattern *dsl.Pattern) ([]*dsl.Test, error) {
return nil, fmt.Errorf("no directories matched %q", pattern.DirsGlob)
}
var tests []*dsl.Test
var cases []patternCase
for _, dir := range dirs {
info, err := os.Stat(dir)
if err != nil || !info.IsDir() {
continue
}
cases = append(cases, patternCase{
name: filepath.Base(dir),
inputPath: filepath.Join(dir, pattern.InputFile),
outputPath: filepath.Join(dir, pattern.OutputFile),
dir: dir,
})
}
return buildTests(cases, pattern.Args)
}
inputPath := filepath.Join(dir, pattern.InputFile)
outputPath := filepath.Join(dir, pattern.OutputFile)
func buildTests(cases []patternCase, argTemplate []string) ([]*dsl.Test, error) {
useInputAsFile := argsContain(argTemplate, "{input_path}")
useOutputAsFile := argsContain(argTemplate, "{output_path}")
inputContent, err := os.ReadFile(inputPath)
var tests []*dsl.Test
for _, c := range cases {
inputContent, err := os.ReadFile(c.inputPath)
if err != nil {
return nil, fmt.Errorf("read %q: %w", inputPath, err)
return nil, fmt.Errorf("read input %q: %w", c.inputPath, err)
}
outputContent, err := os.ReadFile(outputPath)
outputContent, err := os.ReadFile(c.outputPath)
if err != nil {
return nil, fmt.Errorf("read %q: %w", outputPath, err)
return nil, fmt.Errorf("read output %q: %w", c.outputPath, err)
}
name := fmt.Sprintf("pattern:%s", filepath.Base(dir))
stdin := string(inputContent)
expected := string(outputContent)
tests = append(tests, &dsl.Test{
Name: name,
Stdin: &stdin,
t := &dsl.Test{
Name: fmt.Sprintf("pattern:%s", c.name),
Env: map[string]string{},
InFiles: map[string]string{},
OutFiles: map[string]string{},
Stdout: dsl.ExactMatcher{Value: expected},
Stderr: dsl.NoMatcher{},
})
}
inputName := filepath.Base(c.inputPath)
outputName := filepath.Base(c.outputPath)
if useInputAsFile {
t.InFiles[inputName] = string(inputContent)
} else {
s := string(inputContent)
t.Stdin = &s
}
if useOutputAsFile {
t.OutFiles[outputName] = string(outputContent)
t.Stdout = dsl.NoMatcher{}
} else {
t.Stdout = dsl.ExactMatcher{Value: string(outputContent)}
}
if len(argTemplate) > 0 {
t.Args = substituteArgs(argTemplate, map[string]string{
"{input_path}": inputName,
"{output_path}": outputName,
"{name}": c.name,
"{dir}": c.dir,
})
}
tests = append(tests, t)
}
return tests, nil
}
func argsContain(args []string, placeholder string) bool {
for _, a := range args {
if strings.Contains(a, placeholder) {
return true
}
}
return false
}
func substituteArgs(template []string, vars map[string]string) []string {
out := make([]string, len(template))
for i, a := range template {
for k, v := range vars {
a = strings.ReplaceAll(a, k, v)
}
out[i] = a
}
return out
}
func splitGlob(pattern string) (prefix, suffix string) {
before, after, found := strings.Cut(pattern, "*")
if !found {

240
runner/expander_test.go Normal file
View File

@@ -0,0 +1,240 @@
package runner
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/Mond1c/judge/dsl"
)
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
func TestExpandGlobPairedStdioMode(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "tests/01.in", "1 2 3\n")
writeFile(t, dir, "tests/01.ans", "6\n")
writeFile(t, dir, "tests/02.in", "10 20\n")
writeFile(t, dir, "tests/02.ans", "30\n")
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)
tests, err := expandPattern(&dsl.Pattern{
InputGlob: "tests/*.in",
OutputGlob: "tests/*.ans",
})
if err != nil {
t.Fatal(err)
}
if len(tests) != 2 {
t.Fatalf("expected 2 tests, got %d", len(tests))
}
for _, tc := range tests {
if tc.Stdin == nil {
t.Errorf("%s: stdin should be set in stdio mode", tc.Name)
}
if len(tc.Args) != 0 {
t.Errorf("%s: args should be empty without template", tc.Name)
}
if _, ok := tc.Stdout.(dsl.ExactMatcher); !ok {
t.Errorf("%s: stdout should be ExactMatcher, got %T", tc.Name, tc.Stdout)
}
}
}
func TestExpandGlobWithSharedOutput(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "tests/01.in", "1\n")
writeFile(t, dir, "tests/02.in", "2\n")
writeFile(t, dir, "tests/03.in", "3\n")
writeFile(t, dir, "expected.ans", "ok\n")
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)
tests, err := expandPattern(&dsl.Pattern{
InputGlob: "tests/*.in",
OutputGlob: "expected.ans",
})
if err != nil {
t.Fatalf("expand: %v", err)
}
if len(tests) != 3 {
t.Fatalf("expected 3 tests, got %d", len(tests))
}
for _, tc := range tests {
m, ok := tc.Stdout.(dsl.ExactMatcher)
if !ok {
t.Fatalf("%s: stdout should be ExactMatcher, got %T", tc.Name, tc.Stdout)
}
if m.Value != "ok\n" {
t.Errorf("%s: shared output = %q, want %q", tc.Name, m.Value, "ok\n")
}
}
}
func TestExpandGlobFileModeInputOnly(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "tests/01.in", "hello\n")
writeFile(t, dir, "tests/01.ans", "HELLO\n")
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)
tests, err := expandPattern(&dsl.Pattern{
InputGlob: "tests/*.in",
OutputGlob: "tests/*.ans",
Args: []string{"{input_path}"},
})
if err != nil {
t.Fatal(err)
}
if len(tests) != 1 {
t.Fatalf("expected 1 test, got %d", len(tests))
}
tc := tests[0]
if tc.Stdin != nil {
t.Errorf("stdin should be nil in file mode for input")
}
if content, ok := tc.InFiles["01.in"]; !ok || content != "hello\n" {
t.Errorf("InFiles[01.in] = %q, want %q", content, "hello\n")
}
if _, ok := tc.Stdout.(dsl.ExactMatcher); !ok {
t.Errorf("stdout should still be ExactMatcher when output not in file mode, got %T", tc.Stdout)
}
if len(tc.Args) != 1 || tc.Args[0] != "01.in" {
t.Errorf("args = %v, want [01.in]", tc.Args)
}
}
func TestExpandGlobFileModeBoth(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "tests/01.in", "1 2 3\n")
writeFile(t, dir, "tests/01.ans", "6\n")
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)
tests, err := expandPattern(&dsl.Pattern{
InputGlob: "tests/*.in",
OutputGlob: "tests/*.ans",
Args: []string{"{input_path}", "{output_path}"},
})
if err != nil {
t.Fatal(err)
}
tc := tests[0]
if tc.Stdin != nil {
t.Error("stdin should be nil")
}
if _, ok := tc.Stdout.(dsl.NoMatcher); !ok {
t.Errorf("stdout should be NoMatcher when output is file, got %T", tc.Stdout)
}
if content := tc.OutFiles["01.ans"]; content != "6\n" {
t.Errorf("OutFiles[01.ans] = %q, want %q", content, "6\n")
}
if len(tc.Args) != 2 || tc.Args[0] != "01.in" || tc.Args[1] != "01.ans" {
t.Errorf("args = %v, want [01.in 01.ans]", tc.Args)
}
}
func TestExpandGlobArgsWithStaticAndPlaceholders(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "tests/01.in", "x\n")
writeFile(t, dir, "tests/01.ans", "x\n")
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)
tests, err := expandPattern(&dsl.Pattern{
InputGlob: "tests/*.in",
OutputGlob: "tests/*.ans",
Args: []string{"--mode", "strict", "{input_path}"},
})
if err != nil {
t.Fatal(err)
}
if len(tests[0].Args) != 3 || tests[0].Args[0] != "--mode" || tests[0].Args[2] != "01.in" {
t.Errorf("args = %v", tests[0].Args)
}
}
func TestExpandDirModeWithArgs(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "cases/a/input.txt", "one\n")
writeFile(t, dir, "cases/a/expected.txt", "ONE\n")
writeFile(t, dir, "cases/b/input.txt", "two\n")
writeFile(t, dir, "cases/b/expected.txt", "TWO\n")
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)
tests, err := expandPattern(&dsl.Pattern{
DirsGlob: "cases/*",
InputFile: "input.txt",
OutputFile: "expected.txt",
Args: []string{"{input_path}", "{output_path}"},
})
if err != nil {
t.Fatal(err)
}
if len(tests) != 2 {
t.Fatalf("expected 2 tests, got %d", len(tests))
}
for _, tc := range tests {
if _, ok := tc.InFiles["input.txt"]; !ok {
t.Errorf("%s: missing InFiles[input.txt]", tc.Name)
}
if _, ok := tc.OutFiles["expected.txt"]; !ok {
t.Errorf("%s: missing OutFiles[expected.txt]", tc.Name)
}
if len(tc.Args) != 2 {
t.Errorf("%s: args = %v", tc.Name, tc.Args)
}
}
}
func TestExpandPatternRejectsAllLiterals(t *testing.T) {
_, err := expandPattern(&dsl.Pattern{
InputGlob: "tests/a.in",
OutputGlob: "tests/a.ans",
})
if err == nil {
t.Fatal("expected error when no glob fields")
}
if !strings.Contains(err.Error(), "glob") {
t.Errorf("error %q does not mention glob", err.Error())
}
}
func TestExpandPatternNoMatches(t *testing.T) {
dir := t.TempDir()
cwd, _ := os.Getwd()
defer os.Chdir(cwd)
os.Chdir(dir)
_, err := expandPattern(&dsl.Pattern{
InputGlob: "missing/*.in",
OutputGlob: "missing/*.ans",
})
if err == nil {
t.Fatal("expected error on zero matches")
}
}

View File

@@ -2,6 +2,7 @@ package runner
import (
"fmt"
"math"
"time"
)
@@ -40,8 +41,8 @@ type TestResult struct {
Status Status
Elapsed time.Duration
PeakMemory int64 // bytes; 0 if not measured
MemoryLimit int64 // bytes; 0 if unlimited
PeakMemory int64
MemoryLimit int64
Failures []string
@@ -64,8 +65,40 @@ type GroupResult struct {
Total int
}
type SuiteResult struct {
type BuildRun struct {
Name string
Toolchain string
Skipped bool
SkipReason string
BuildLog string
Groups []*GroupResult
TotalScore float64
BuildLog string
}
type SuiteResult struct {
Builds []*BuildRun
TotalScore float64
}
func (r *SuiteResult) AggregateScore() float64 {
if len(r.Builds) == 0 {
return 0
}
min := math.Inf(1)
anyRan := false
for _, b := range r.Builds {
if b.Skipped {
continue
}
anyRan = true
if b.TotalScore < min {
min = b.TotalScore
}
}
if !anyRan {
return 1.0
}
return min
}

57
runner/result_test.go Normal file
View File

@@ -0,0 +1,57 @@
package runner
import "testing"
func TestAggregateScoreEmpty(t *testing.T) {
r := &SuiteResult{}
if got := r.AggregateScore(); got != 0 {
t.Errorf("empty aggregate = %v, want 0", got)
}
}
func TestAggregateScoreSingleBuild(t *testing.T) {
r := &SuiteResult{
Builds: []*BuildRun{{Name: "release", TotalScore: 0.75}},
}
if got := r.AggregateScore(); got != 0.75 {
t.Errorf("single build aggregate = %v, want 0.75", got)
}
}
func TestAggregateScoreTakesMinimum(t *testing.T) {
r := &SuiteResult{
Builds: []*BuildRun{
{Name: "release", TotalScore: 1.0},
{Name: "debug", TotalScore: 0.9},
{Name: "sanitized", TotalScore: 0.95},
},
}
if got := r.AggregateScore(); got != 0.9 {
t.Errorf("aggregate = %v, want 0.9 (minimum)", got)
}
}
func TestAggregateScoreIgnoresSkipped(t *testing.T) {
r := &SuiteResult{
Builds: []*BuildRun{
{Name: "release", TotalScore: 1.0},
{Name: "sanitized", Skipped: true, SkipReason: "platforms=linux"},
{Name: "debug", TotalScore: 0.8},
},
}
if got := r.AggregateScore(); got != 0.8 {
t.Errorf("aggregate with skipped = %v, want 0.8", got)
}
}
func TestAggregateScoreAllSkipped(t *testing.T) {
r := &SuiteResult{
Builds: []*BuildRun{
{Name: "a", Skipped: true},
{Name: "b", Skipped: true},
},
}
if got := r.AggregateScore(); got != 1.0 {
t.Errorf("all-skipped aggregate = %v, want 1.0", got)
}
}

View File

@@ -21,6 +21,8 @@ type Config struct {
WorkDir string
BinaryName string
Wrapper string
TargetBuild string
}
type Runner struct {
@@ -63,41 +65,246 @@ func (r *Runner) Run() *SuiteResult {
result := &SuiteResult{}
buildLog, err := r.build()
result.BuildLog = buildLog
if err != nil {
for _, g := range r.file.Groups {
gr := &GroupResult{
Name: g.Name,
Weight: g.Weight,
Score: 0,
}
if len(r.file.Builds) == 0 {
run := r.runLegacyBuild()
result.Builds = append(result.Builds, run)
} else {
result.Builds = r.runStructuredBuilds()
}
total := len(g.Tests)
if g.Pattern != nil {
total = -1
}
gr.Total = total
for _, t := range g.Tests {
gr.Tests = append(gr.Tests, &TestResult{
Name: t.Name,
Status: StatusBuildError,
})
}
result.Groups = append(result.Groups, gr)
}
return result
result.TotalScore = result.AggregateScore()
return result
}
func (r *Runner) runLegacyBuild() *BuildRun {
run := &BuildRun{Name: "default"}
if r.cfg.TargetBuild != "" && r.cfg.TargetBuild != "default" {
run.Skipped = true
run.SkipReason = fmt.Sprintf("--build=%q selected, but this suite has no structured builds", r.cfg.TargetBuild)
return run
}
buildLog, err := r.legacyBuild()
run.BuildLog = buildLog
if err != nil {
r.fillBuildError(run)
return run
}
r.binary = resolveBinary(r.cfg.WorkDir, filepath.Base(r.binary))
r.runGroups(run)
return run
}
for _, g := range r.file.Groups {
gr := r.runGroup(g)
result.Groups = append(result.Groups, gr)
result.TotalScore += gr.Score
func (r *Runner) resolveRuntimeToolchain() (Toolchain, string) {
goos := runtime.GOOS
wanted := os.Getenv("JUDGE_TOOLCHAIN")
if wanted == "" {
wanted = os.Getenv("JUDGE_CC")
}
for _, spec := range r.file.Toolchains {
if spec.Name == wanted {
return ResolveToolchainSpec(spec), goos
}
}
return ResolveToolchain(wanted), goos
}
func (r *Runner) runStructuredBuilds() []*BuildRun {
tc, goos := r.resolveRuntimeToolchain()
var runs []*BuildRun
for _, b := range r.file.Builds {
run := &BuildRun{Name: b.Name, Toolchain: tc.Name}
if r.cfg.TargetBuild != "" && r.cfg.TargetBuild != b.Name {
continue
}
effective := b.Resolve(r.file.BuildDefaults, goos)
if !effective.AppliesTo(goos, tc.Name) {
run.Skipped = true
run.SkipReason = fmt.Sprintf("not applicable to %s/%s (platforms=%v, compilers=%v)", goos, tc.Name, effective.Platforms, effective.Compilers)
runs = append(runs, run)
continue
}
log, binaryPath, err := r.compileStructured(b.Name, effective, tc)
run.BuildLog = log
if err != nil {
run.Groups = r.synthesizeBuildError()
run.TotalScore = 0
runs = append(runs, run)
continue
}
prevBinary := r.binary
prevWrapper := r.cfg.Wrapper
r.binary = binaryPath
if r.cfg.Wrapper == "" && effective.Wrapper != "" {
r.cfg.Wrapper = effective.Wrapper
}
r.runGroups(run)
r.binary = prevBinary
r.cfg.Wrapper = prevWrapper
runs = append(runs, run)
}
return result
return runs
}
func (r *Runner) runGroups(run *BuildRun) {
for _, g := range r.file.Groups {
gr := r.runGroup(g)
run.Groups = append(run.Groups, gr)
run.TotalScore += gr.Score
}
}
func (r *Runner) fillBuildError(run *BuildRun) {
run.Groups = r.synthesizeBuildError()
}
func (r *Runner) synthesizeBuildError() []*GroupResult {
var out []*GroupResult
for _, g := range r.file.Groups {
gr := &GroupResult{
Name: g.Name,
Weight: g.Weight,
Score: 0,
}
total := len(g.Tests)
if g.Pattern != nil {
total = -1
}
gr.Total = total
for _, t := range g.Tests {
gr.Tests = append(gr.Tests, &TestResult{
Name: t.Name,
Status: StatusBuildError,
})
}
out = append(out, gr)
}
return out
}
func (r *Runner) legacyBuild() (string, error) {
buildCmd := r.buildCommand()
sources, err := r.findSources()
if err != nil {
return "", err
}
if sources != "" {
buildCmd = strings.ReplaceAll(buildCmd, "$SOURCES", sources)
}
ctx := context.Background()
if r.file.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.file.Timeout)
defer cancel()
}
cmd := shellCommand(ctx, buildCmd)
cmd.Dir = r.cfg.WorkDir
setProcessGroup(cmd)
cmd.Env = os.Environ()
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
killProcessGroup(cmd)
return out.String(), fmt.Errorf("build failed: %w\n%s", err, out.String())
}
return out.String(), nil
}
func (r *Runner) compileStructured(name string, cfg dsl.BuildConfig, tc Toolchain) (string, string, error) {
sources, err := expandSources(r.cfg.WorkDir, cfg.Sources)
if err != nil {
return "", "", err
}
if len(sources) == 0 {
return "", "", fmt.Errorf("build %q: no sources", name)
}
cfg.Sources = sources
outputName := cfg.Output
if outputName == "" {
outputName = "solution"
}
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(outputName), ".exe") {
outputName += ".exe"
}
buildDir := filepath.Join(r.cfg.WorkDir, "build", name)
if err := os.MkdirAll(buildDir, 0755); err != nil {
return "", "", fmt.Errorf("mkdir %s: %w", buildDir, err)
}
outputPath := filepath.Join(buildDir, outputName)
argv, err := Compile(cfg, tc, outputPath)
if err != nil {
return "", "", err
}
ctx := context.Background()
if r.file.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.file.Timeout)
defer cancel()
}
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
cmd.Dir = r.cfg.WorkDir
setProcessGroup(cmd)
cmd.Env = os.Environ()
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
logPrefix := fmt.Sprintf("$ %s\n", strings.Join(argv, " "))
if err := cmd.Run(); err != nil {
killProcessGroup(cmd)
return logPrefix + out.String(), "", fmt.Errorf("build %q failed: %w\n%s", name, err, out.String())
}
return logPrefix + out.String(), outputPath, nil
}
func expandSources(workDir string, patterns []string) ([]string, error) {
var out []string
seen := map[string]bool{}
for _, pat := range patterns {
matches, err := filepath.Glob(filepath.Join(workDir, pat))
if err != nil {
return nil, fmt.Errorf("glob %q: %w", pat, err)
}
if len(matches) == 0 {
if _, statErr := os.Stat(filepath.Join(workDir, pat)); statErr == nil {
matches = []string{filepath.Join(workDir, pat)}
} else {
return nil, fmt.Errorf("source glob %q matched no files", pat)
}
}
for _, m := range matches {
rel, err := filepath.Rel(workDir, m)
if err != nil {
rel = m
}
rel = filepath.ToSlash(rel)
if !seen[rel] {
seen[rel] = true
out = append(out, rel)
}
}
}
return out, nil
}
func (r *Runner) buildCommand() string {
@@ -152,40 +359,6 @@ func (r *Runner) findSources() (string, error) {
return strings.Join(files, " "), nil
}
func (r *Runner) build() (string, error) {
buildCmd := r.buildCommand()
sources, err := r.findSources()
if err != nil {
return "", err
}
if sources != "" {
buildCmd = strings.ReplaceAll(buildCmd, "$SOURCES", sources)
}
ctx := context.Background()
if r.file.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.file.Timeout)
defer cancel()
}
cmd := shellCommand(ctx, buildCmd)
cmd.Dir = r.cfg.WorkDir
setProcessGroup(cmd)
cmd.Env = os.Environ()
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
if err := cmd.Run(); err != nil {
killProcessGroup(cmd)
return out.String(), fmt.Errorf("build failed: %w\n%s", err, out.String())
}
return out.String(), nil
}
func shellCommand(ctx context.Context, cmdline string) *exec.Cmd {
if runtime.GOOS == "windows" {
return exec.CommandContext(ctx, "cmd", "/C", cmdline)
@@ -392,21 +565,23 @@ func (r *Runner) runTest(t *dsl.Test) *TestResult {
tr.addFailure("%s", f)
}
for name, expected := range t.OutFiles {
path := filepath.Join(tmpDir, name)
content, err := os.ReadFile(path)
if err != nil {
tr.addFailure("output file %q not found: %v", name, err)
continue
}
actual := normalizeOutput(string(content), r.file)
for _, f := range applyMatcher(fmt.Sprintf("file(%s)", name), dsl.ExactMatcher{Value: expected}, actual) {
tr.addFailure("%s", f)
}
if len(tr.Failures) > 0 {
tr.Status = StatusFail
}
if tr.Status == StatusPass && len(tr.Failures) > 0 {
tr.Status = StatusFail
for name, expected := range t.OutFiles {
actualPath := filepath.Join(tmpDir, name)
data, err := os.ReadFile(actualPath)
if err != nil {
tr.Status = StatusFail
tr.addFailure("output file %q missing: %v", name, err)
continue
}
actual := normalizeOutput(string(data), r.file)
if actual != expected {
tr.Status = StatusFail
tr.addFailure("output file %q mismatch\n expected: %q\n actual: %q", name, expected, actual)
}
}
return tr