add new build system
All checks were successful
build-dsl-smoke / Discover matrix (push) Successful in 8s
build-dsl-smoke / Build judge (push) Successful in 11s
build-dsl-smoke / ${{ matrix.cell.build }} / ${{ matrix.cell.toolchain }} / ${{ matrix.cell.platform }} (push) Successful in 5s
memory-limit / Build judge (pull_request) Successful in 10s
build-dsl-smoke / SUMMARY (push) Successful in 3s
memory-limit / Linux / gcc (pull_request) Successful in 9s
memory-limit / Linux / clang (pull_request) Successful in 13s
memory-limit / Windows / clang (pull_request) Successful in 16s
memory-limit / Windows / msvc (pull_request) Successful in 17s
All checks were successful
build-dsl-smoke / Discover matrix (push) Successful in 8s
build-dsl-smoke / Build judge (push) Successful in 11s
build-dsl-smoke / ${{ matrix.cell.build }} / ${{ matrix.cell.toolchain }} / ${{ matrix.cell.platform }} (push) Successful in 5s
memory-limit / Build judge (pull_request) Successful in 10s
build-dsl-smoke / SUMMARY (push) Successful in 3s
memory-limit / Linux / gcc (pull_request) Successful in 9s
memory-limit / Linux / clang (pull_request) Successful in 13s
memory-limit / Windows / clang (pull_request) Successful in 16s
memory-limit / Windows / msvc (pull_request) Successful in 17s
This commit is contained in:
162
.gitea/workflows/build-dsl-smoke.yml
Normal file
162
.gitea/workflows/build-dsl-smoke.yml
Normal file
@@ -0,0 +1,162 @@
|
||||
name: build-dsl-smoke
|
||||
run-name: "Structured build DSL smoke test"
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'dsl/**'
|
||||
- 'runner/**'
|
||||
- 'reporter/**'
|
||||
- 'cmd/cli/**'
|
||||
- 'example/c-sum-v2/**'
|
||||
- '.gitea/workflows/build-dsl-smoke.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
SUITE_FILE: sum.jdg
|
||||
EXAMPLE_DIR: example/c-sum-v2
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
name: Discover matrix
|
||||
runs-on: Linux-Runner
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
matrix: ${{ steps.list.outputs.matrix }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: List matrix from .jdg
|
||||
id: list
|
||||
shell: bash
|
||||
run: |
|
||||
matrix=$(go run ./cmd/cli --list-matrix "$EXAMPLE_DIR/$SUITE_FILE")
|
||||
echo "discovered: $matrix"
|
||||
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
|
||||
|
||||
build_judge:
|
||||
name: Build judge
|
||||
runs-on: Linux-Runner
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Cross-compile judge
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o dist/judge-linux-amd64 ./cmd/cli
|
||||
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o dist/judge-windows-amd64.exe ./cmd/cli
|
||||
ls -la dist/
|
||||
|
||||
- name: Upload judge binaries
|
||||
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: judge-bin-csum2
|
||||
path: dist/
|
||||
retention-days: 1
|
||||
|
||||
test:
|
||||
needs: [discover, build_judge]
|
||||
name: "${{ matrix.cell.build }} / ${{ matrix.cell.toolchain }} / ${{ matrix.cell.platform }}"
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
cell: ${{ fromJSON(needs.discover.outputs.matrix) }}
|
||||
|
||||
runs-on: ${{ matrix.cell.platform == 'windows' && 'Windows-Runner' || 'Linux-Runner' }}
|
||||
timeout-minutes: 10
|
||||
|
||||
env:
|
||||
REPORT_NAME: "report_${{ matrix.cell.build }}_${{ matrix.cell.toolchain }}_${{ matrix.cell.platform }}"
|
||||
JUDGE_TOOLCHAIN: ${{ matrix.cell.toolchain }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up MSVC environment
|
||||
if: matrix.cell.toolchain == 'msvc'
|
||||
uses: ilammy/msvc-dev-cmd@v1
|
||||
|
||||
- name: Download judge binary
|
||||
uses: https://github.com/christopherHX/gitea-download-artifact@v4
|
||||
with:
|
||||
name: judge-bin-csum2
|
||||
path: judge-bin
|
||||
|
||||
- name: Install judge on PATH
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p bin
|
||||
if [ "${{ matrix.cell.platform }}" = "windows" ]; then
|
||||
cp judge-bin/judge-windows-amd64.exe bin/judge.exe
|
||||
else
|
||||
cp judge-bin/judge-linux-amd64 bin/judge
|
||||
chmod +x bin/judge
|
||||
fi
|
||||
echo "$PWD/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Run judge
|
||||
shell: bash
|
||||
working-directory: ${{ env.EXAMPLE_DIR }}
|
||||
run: |
|
||||
judge "$SUITE_FILE" . --build=${{ matrix.cell.build }} --json > "$GITHUB_WORKSPACE/${REPORT_NAME}.json" || true
|
||||
cat "$GITHUB_WORKSPACE/${REPORT_NAME}.json"
|
||||
judge "$SUITE_FILE" . --build=${{ matrix.cell.build }}
|
||||
|
||||
- name: Upload report
|
||||
if: ${{ always() }}
|
||||
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: ${{ env.REPORT_NAME }}
|
||||
path: ${{ env.REPORT_NAME }}.json
|
||||
retention-days: 7
|
||||
compression-level: 9
|
||||
|
||||
summary:
|
||||
needs: [build_judge, test]
|
||||
if: ${{ always() }}
|
||||
name: SUMMARY
|
||||
runs-on: Linux-Runner
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Download judge binary
|
||||
uses: https://github.com/christopherHX/gitea-download-artifact@v4
|
||||
with:
|
||||
name: judge-bin-csum2
|
||||
path: judge-bin
|
||||
|
||||
- name: Install judge on PATH
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p bin
|
||||
cp judge-bin/judge-linux-amd64 bin/judge
|
||||
chmod +x bin/judge
|
||||
echo "$PWD/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Download all reports
|
||||
uses: https://github.com/christopherHX/gitea-download-artifact@v4
|
||||
with:
|
||||
path: reports
|
||||
pattern: report_*
|
||||
|
||||
- name: Aggregate
|
||||
shell: bash
|
||||
run: judge aggregate reports | tee SUMMARY.md
|
||||
|
||||
- name: Upload summary
|
||||
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: SUMMARY
|
||||
path: SUMMARY.md
|
||||
retention-days: 7
|
||||
139
cmd/cli/main.go
139
cmd/cli/main.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -20,11 +21,16 @@ Flags:
|
||||
--json output as JSON instead of text
|
||||
--wrapper <cmd> exec wrapper (e.g. "valgrind --error-exitcode=99")
|
||||
--binary <name> name of executable produced by build (overrides .jdg)
|
||||
--build <name> run only the named structured build (use with matrix CI)
|
||||
--list-builds print the names of structured builds in the suite as JSON
|
||||
--list-matrix print the full (build, toolchain, platform) matrix as JSON
|
||||
--help show help
|
||||
|
||||
Example:
|
||||
judge lab1.jdg ./student-solution
|
||||
judge lab1.jdg ./student-solution --json
|
||||
judge lab1.jdg ./student-solution --build=sanitized
|
||||
judge --list-builds lab1.jdg
|
||||
judge aggregate reports/
|
||||
`
|
||||
|
||||
@@ -41,9 +47,20 @@ func main() {
|
||||
return
|
||||
}
|
||||
|
||||
if hasFlag(args, "--list-builds") {
|
||||
runListBuilds(args)
|
||||
return
|
||||
}
|
||||
|
||||
if hasFlag(args, "--list-matrix") {
|
||||
runListMatrix(args)
|
||||
return
|
||||
}
|
||||
|
||||
jsonOutput := hasFlag(args, "--json")
|
||||
wrapper := flagValue(args, "--wrapper")
|
||||
binary := flagValue(args, "--binary")
|
||||
targetBuild := flagValue(args, "--build")
|
||||
positional := positionalArgs(args)
|
||||
|
||||
if len(positional) < 2 {
|
||||
@@ -54,18 +71,7 @@ func main() {
|
||||
testFile := positional[0]
|
||||
solutionDir := positional[1]
|
||||
|
||||
src, err := os.ReadFile(testFile)
|
||||
if err != nil {
|
||||
fatalf("cannot read %q: %v", testFile, err)
|
||||
}
|
||||
|
||||
f, warns, err := dsl.Parse(string(src))
|
||||
if err != nil {
|
||||
fatalf("parse error in %q:\n %v", testFile, err)
|
||||
}
|
||||
for _, w := range warns {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s\n", w)
|
||||
}
|
||||
f := parseSuite(testFile)
|
||||
|
||||
if _, err := os.Stat(solutionDir); err != nil {
|
||||
fatalf("solution dir %q not found: %v", solutionDir, err)
|
||||
@@ -75,6 +81,7 @@ func main() {
|
||||
WorkDir: solutionDir,
|
||||
BinaryName: binary,
|
||||
Wrapper: wrapper,
|
||||
TargetBuild: targetBuild,
|
||||
})
|
||||
result := r.Run()
|
||||
|
||||
@@ -91,6 +98,98 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func parseSuite(path string) *dsl.File {
|
||||
src, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
fatalf("cannot read %q: %v", path, err)
|
||||
}
|
||||
f, warns, err := dsl.Parse(string(src))
|
||||
if err != nil {
|
||||
fatalf("parse error in %q:\n %v", path, err)
|
||||
}
|
||||
for _, w := range warns {
|
||||
fmt.Fprintf(os.Stderr, "warning: %s\n", w)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// runListBuilds prints a JSON array of build names for CI matrix discovery.
|
||||
// A legacy suite without structured builds reports ["default"] so workflows
|
||||
// can always iterate `fromJSON` and have exactly one cell.
|
||||
func runListBuilds(args []string) {
|
||||
positional := positionalArgs(args)
|
||||
if len(positional) < 1 {
|
||||
fatalf("--list-builds requires the path to a .jdg file")
|
||||
}
|
||||
f := parseSuite(positional[0])
|
||||
|
||||
var names []string
|
||||
if len(f.Builds) == 0 {
|
||||
names = []string{"default"}
|
||||
} else {
|
||||
for _, b := range f.Builds {
|
||||
names = append(names, b.Name)
|
||||
}
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
if err := enc.Encode(names); err != nil {
|
||||
fatalf("encode list-builds: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type matrixEntry struct {
|
||||
Build string `json:"build"`
|
||||
Toolchain string `json:"toolchain"`
|
||||
Platform string `json:"platform"`
|
||||
Wrapper string `json:"wrapper,omitempty"`
|
||||
}
|
||||
|
||||
func runListMatrix(args []string) {
|
||||
positional := positionalArgs(args)
|
||||
if len(positional) < 1 {
|
||||
fatalf("--list-matrix requires the path to a .jdg file")
|
||||
}
|
||||
f := parseSuite(positional[0])
|
||||
|
||||
var entries []matrixEntry
|
||||
|
||||
if len(f.Builds) == 0 {
|
||||
if len(f.Toolchains) == 0 {
|
||||
entries = append(entries, matrixEntry{Build: "default", Toolchain: "default", Platform: "linux"})
|
||||
} else {
|
||||
for _, tc := range f.Toolchains {
|
||||
for _, platform := range tc.Platforms {
|
||||
entries = append(entries, matrixEntry{Build: "default", Toolchain: tc.Name, Platform: platform})
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if len(f.Toolchains) == 0 {
|
||||
fatalf("suite has structured builds but no `toolchains { ... }` block; add one to use --list-matrix")
|
||||
} else {
|
||||
for _, b := range f.Builds {
|
||||
for _, tc := range f.Toolchains {
|
||||
for _, platform := range tc.Platforms {
|
||||
eff := b.Resolve(f.BuildDefaults, platform)
|
||||
if !eff.AppliesTo(platform, tc.Name) {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, matrixEntry{
|
||||
Build: b.Name,
|
||||
Toolchain: tc.Name,
|
||||
Platform: platform,
|
||||
Wrapper: eff.Wrapper,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
if err := enc.Encode(entries); err != nil {
|
||||
fatalf("encode list-matrix: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runAggregate(args []string) {
|
||||
if len(args) < 1 {
|
||||
fatalf("usage: judge aggregate <reports-dir>")
|
||||
@@ -128,8 +227,18 @@ func flagValue(args []string, name string) string {
|
||||
}
|
||||
|
||||
func positionalArgs(args []string) []string {
|
||||
known := map[string]bool{"--json": true, "--help": true, "-h": true}
|
||||
withValue := map[string]bool{"--wrapper": true, "--binary": true}
|
||||
known := map[string]bool{
|
||||
"--json": true,
|
||||
"--help": true,
|
||||
"-h": true,
|
||||
"--list-builds": true,
|
||||
"--list-matrix": true,
|
||||
}
|
||||
withValue := map[string]bool{
|
||||
"--wrapper": true,
|
||||
"--binary": true,
|
||||
"--build": true,
|
||||
}
|
||||
|
||||
var out []string
|
||||
skip := false
|
||||
@@ -145,7 +254,7 @@ func positionalArgs(args []string) []string {
|
||||
skip = true
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(a, "--wrapper=") || strings.HasPrefix(a, "--binary=") {
|
||||
if strings.HasPrefix(a, "--wrapper=") || strings.HasPrefix(a, "--binary=") || strings.HasPrefix(a, "--build=") {
|
||||
continue
|
||||
}
|
||||
out = append(out, a)
|
||||
|
||||
23
dsl/ast.go
23
dsl/ast.go
@@ -7,13 +7,18 @@ type File struct {
|
||||
BuildLinux string
|
||||
BuildWindows string
|
||||
BuildDarwin string
|
||||
Timeout time.Duration
|
||||
MemoryLimit int64 // bytes; 0 means no limit
|
||||
Binary string // executable name produced by build (default: solution)
|
||||
Sources string // glob pattern for source files, expanded as $SOURCES in build
|
||||
|
||||
NormalizeCRLF bool // strip \r before matching stdout/stderr/outFiles
|
||||
TrimTrailingWS bool // trim trailing whitespace on each line before matching
|
||||
BuildDefaults *BuildConfig
|
||||
Builds []*BuildConfig
|
||||
Toolchains []*ToolchainSpec
|
||||
|
||||
Timeout time.Duration
|
||||
MemoryLimit int64
|
||||
Binary string
|
||||
Sources string
|
||||
|
||||
NormalizeCRLF bool
|
||||
TrimTrailingWS bool
|
||||
|
||||
Groups []*Group
|
||||
}
|
||||
@@ -25,7 +30,7 @@ type Group struct {
|
||||
MemoryLimit int64
|
||||
Env map[string]string
|
||||
Scoring ScoringMode
|
||||
Wrapper string // exec wrapper command (e.g., "valgrind --error-exitcode=1")
|
||||
Wrapper string
|
||||
|
||||
Tests []*Test
|
||||
Pattern *Pattern
|
||||
@@ -34,8 +39,8 @@ type Group struct {
|
||||
type ScoringMode int
|
||||
|
||||
const (
|
||||
ScoringPartial ScoringMode = iota // weight * passed/total (default)
|
||||
ScoringAllOrNone // weight or 0
|
||||
ScoringPartial ScoringMode = iota
|
||||
ScoringAllOrNone
|
||||
)
|
||||
|
||||
type Pattern struct {
|
||||
|
||||
187
dsl/build.go
Normal file
187
dsl/build.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package dsl
|
||||
|
||||
// BuildProfile is a named compilation profile. The translator maps it into
|
||||
// compiler-specific flag sets at execution time (e.g. ProfileRelease → "-O2"
|
||||
// on gnu-like compilers, "/O2" on msvc).
|
||||
type BuildProfile int
|
||||
|
||||
const (
|
||||
ProfileUnset BuildProfile = iota
|
||||
ProfileRelease
|
||||
ProfileDebug
|
||||
ProfileSanitized
|
||||
)
|
||||
|
||||
func (p BuildProfile) String() string {
|
||||
switch p {
|
||||
case ProfileRelease:
|
||||
return "release"
|
||||
case ProfileDebug:
|
||||
return "debug"
|
||||
case ProfileSanitized:
|
||||
return "sanitized"
|
||||
default:
|
||||
return "unset"
|
||||
}
|
||||
}
|
||||
|
||||
// WarningLevel describes how strict the compiler should be about warnings.
|
||||
type WarningLevel int
|
||||
|
||||
const (
|
||||
WarningsUnset WarningLevel = iota
|
||||
WarningsDefault
|
||||
WarningsStrict
|
||||
WarningsPedantic
|
||||
)
|
||||
|
||||
func (w WarningLevel) String() string {
|
||||
switch w {
|
||||
case WarningsDefault:
|
||||
return "default"
|
||||
case WarningsStrict:
|
||||
return "strict"
|
||||
case WarningsPedantic:
|
||||
return "pedantic"
|
||||
default:
|
||||
return "unset"
|
||||
}
|
||||
}
|
||||
|
||||
// BuildConfig describes one structured build variant. It is the new-style
|
||||
// replacement for the free-form `build "shell-string"` field.
|
||||
//
|
||||
// A top-level `build_defaults { ... }` in the suite file produces a
|
||||
// BuildConfig stored on File.BuildDefaults. Each `build "name" { ... }`
|
||||
// block produces an entry in File.Builds; the effective configuration used
|
||||
// by the runner is BuildDefaults merged with the named block, then merged
|
||||
// with the OS-specific override (Linux / Windows / Darwin) when present.
|
||||
//
|
||||
// Zero-valued fields inherit from the parent during merge. Slice and map
|
||||
// fields accumulate rather than replace.
|
||||
type BuildConfig struct {
|
||||
// Name of the variant. Empty on BuildDefaults and on OS-override sub-blocks.
|
||||
Name string
|
||||
|
||||
Language string // e.g. "c", "c++"
|
||||
Standard string // e.g. "c11", "c++17"
|
||||
Sources []string // globs, relative to work dir
|
||||
Includes []string // include search paths
|
||||
Output string // binary name (OS-specific extension added automatically)
|
||||
|
||||
Profile BuildProfile
|
||||
Warnings WarningLevel
|
||||
Sanitize []string
|
||||
Wrapper string // e.g. "address", "undefined", "thread"
|
||||
|
||||
Defines map[string]string
|
||||
Link []string // libraries to link against (e.g. "pthread", "m")
|
||||
Extra []string // raw passthrough flags
|
||||
|
||||
// Filters — empty means "applies to any". A build is skipped at runtime
|
||||
// if the current OS or compiler is not in the list.
|
||||
Platforms []string // "linux", "windows", "darwin"
|
||||
Compilers []string // "gcc", "clang", "msvc"
|
||||
|
||||
// OS-specific overrides. Only one level of nesting is allowed: these
|
||||
// sub-configs must not themselves contain Linux/Windows/Darwin blocks.
|
||||
Linux *BuildConfig
|
||||
Windows *BuildConfig
|
||||
Darwin *BuildConfig
|
||||
}
|
||||
|
||||
// MergeFrom layers src on top of dst in place. Non-zero scalar fields in src
|
||||
// overwrite dst; slices and maps accumulate. The Name and OS override fields
|
||||
// on src are intentionally ignored — merging never copies the hierarchy,
|
||||
// only the leaves.
|
||||
func (dst *BuildConfig) MergeFrom(src *BuildConfig) {
|
||||
if src == nil {
|
||||
return
|
||||
}
|
||||
if src.Language != "" {
|
||||
dst.Language = src.Language
|
||||
}
|
||||
if src.Standard != "" {
|
||||
dst.Standard = src.Standard
|
||||
}
|
||||
if src.Output != "" {
|
||||
dst.Output = src.Output
|
||||
}
|
||||
if src.Profile != ProfileUnset {
|
||||
dst.Profile = src.Profile
|
||||
}
|
||||
if src.Warnings != WarningsUnset {
|
||||
dst.Warnings = src.Warnings
|
||||
}
|
||||
if src.Wrapper != "" {
|
||||
dst.Wrapper = src.Wrapper
|
||||
}
|
||||
|
||||
dst.Sources = append(dst.Sources, src.Sources...)
|
||||
dst.Includes = append(dst.Includes, src.Includes...)
|
||||
dst.Sanitize = append(dst.Sanitize, src.Sanitize...)
|
||||
dst.Link = append(dst.Link, src.Link...)
|
||||
dst.Extra = append(dst.Extra, src.Extra...)
|
||||
dst.Platforms = append(dst.Platforms, src.Platforms...)
|
||||
dst.Compilers = append(dst.Compilers, src.Compilers...)
|
||||
|
||||
if len(src.Defines) > 0 {
|
||||
if dst.Defines == nil {
|
||||
dst.Defines = map[string]string{}
|
||||
}
|
||||
for k, v := range src.Defines {
|
||||
dst.Defines[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve returns the effective BuildConfig for the given OS by merging
|
||||
// BuildDefaults → this block → the matching OS override. The result is a
|
||||
// fresh value; the receiver is not mutated.
|
||||
func (b *BuildConfig) Resolve(defaults *BuildConfig, os string) BuildConfig {
|
||||
var out BuildConfig
|
||||
out.MergeFrom(defaults)
|
||||
out.MergeFrom(b)
|
||||
out.Name = b.Name
|
||||
|
||||
var osOverride *BuildConfig
|
||||
switch os {
|
||||
case "linux":
|
||||
osOverride = b.Linux
|
||||
case "windows":
|
||||
osOverride = b.Windows
|
||||
case "darwin":
|
||||
osOverride = b.Darwin
|
||||
}
|
||||
out.MergeFrom(osOverride)
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// AppliesTo reports whether this build should run on (os, compiler).
|
||||
// An empty Platforms/Compilers list means no filter on that axis.
|
||||
func (b *BuildConfig) AppliesTo(os, compiler string) bool {
|
||||
if len(b.Platforms) > 0 && !contains(b.Platforms, os) {
|
||||
return false
|
||||
}
|
||||
if len(b.Compilers) > 0 && !contains(b.Compilers, compiler) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func contains(xs []string, x string) bool {
|
||||
for _, v := range xs {
|
||||
if v == x {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type ToolchainSpec struct {
|
||||
Name string
|
||||
Platforms []string
|
||||
Binary string
|
||||
Class string
|
||||
}
|
||||
355
dsl/build_parser.go
Normal file
355
dsl/build_parser.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package dsl
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (p *Parser) parseBuildBlock(name string) (*BuildConfig, error) {
|
||||
return p.parseBuildBlockInner(name, false)
|
||||
}
|
||||
|
||||
func (p *Parser) parseBuildBlockInner(name string, inOSOverride bool) (*BuildConfig, error) {
|
||||
if _, err := p.expect(TOKEN_LBRACE); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bc := &BuildConfig{Name: name}
|
||||
|
||||
for !p.isRBrace() {
|
||||
t := p.peek()
|
||||
if t.Type != TOKEN_IDENT {
|
||||
return nil, fmt.Errorf("%d:%d: unexpected token %q in build block", t.Line, t.Col, t.Value)
|
||||
}
|
||||
|
||||
switch t.Value {
|
||||
case "language":
|
||||
p.advance()
|
||||
s, err := p.parseAssignString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Language = s
|
||||
|
||||
case "standard":
|
||||
p.advance()
|
||||
s, err := p.parseAssignString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Standard = s
|
||||
|
||||
case "output":
|
||||
p.advance()
|
||||
s, err := p.parseAssignString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Output = s
|
||||
|
||||
case "wrapper":
|
||||
p.advance()
|
||||
s, err := p.parseAssignString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Wrapper = s
|
||||
|
||||
case "sources":
|
||||
p.advance()
|
||||
xs, err := p.parseAssignStringList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Sources = xs
|
||||
|
||||
case "includes":
|
||||
p.advance()
|
||||
xs, err := p.parseAssignStringList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Includes = xs
|
||||
|
||||
case "sanitize":
|
||||
p.advance()
|
||||
xs, err := p.parseAssignStringList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Sanitize = xs
|
||||
|
||||
case "link":
|
||||
p.advance()
|
||||
xs, err := p.parseAssignStringList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Link = xs
|
||||
|
||||
case "extra":
|
||||
p.advance()
|
||||
xs, err := p.parseAssignStringList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Extra = xs
|
||||
|
||||
case "platforms":
|
||||
p.advance()
|
||||
xs, err := p.parseAssignStringList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePlatformList(xs, t.Line, t.Col); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Platforms = xs
|
||||
|
||||
case "compilers":
|
||||
p.advance()
|
||||
xs, err := p.parseAssignStringList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Compilers = xs
|
||||
|
||||
case "profile":
|
||||
p.advance()
|
||||
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := p.expect(TOKEN_IDENT)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prof, err := parseProfileIdent(id.Value, id.Line, id.Col)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Profile = prof
|
||||
|
||||
case "warnings":
|
||||
p.advance()
|
||||
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := p.expect(TOKEN_IDENT)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, err := parseWarningsIdent(id.Value, id.Line, id.Col)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bc.Warnings = w
|
||||
|
||||
case "define":
|
||||
p.advance()
|
||||
if _, err := p.expect(TOKEN_LPAREN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := p.expect(TOKEN_STRING)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := p.expect(TOKEN_RPAREN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
val, err := p.expect(TOKEN_STRING)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bc.Defines == nil {
|
||||
bc.Defines = map[string]string{}
|
||||
}
|
||||
bc.Defines[key.Value] = val.Value
|
||||
|
||||
case "linux", "windows", "darwin":
|
||||
if inOSOverride {
|
||||
return nil, fmt.Errorf("%d:%d: OS override %q cannot be nested inside another OS override", t.Line, t.Col, t.Value)
|
||||
}
|
||||
osName := t.Value
|
||||
p.advance()
|
||||
sub, err := p.parseBuildBlockInner("", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch osName {
|
||||
case "linux":
|
||||
if bc.Linux != nil {
|
||||
return nil, fmt.Errorf("%d:%d: duplicate linux override", t.Line, t.Col)
|
||||
}
|
||||
bc.Linux = sub
|
||||
case "windows":
|
||||
if bc.Windows != nil {
|
||||
return nil, fmt.Errorf("%d:%d: duplicate windows override", t.Line, t.Col)
|
||||
}
|
||||
bc.Windows = sub
|
||||
case "darwin":
|
||||
if bc.Darwin != nil {
|
||||
return nil, fmt.Errorf("%d:%d: duplicate darwin override", t.Line, t.Col)
|
||||
}
|
||||
bc.Darwin = sub
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%d:%d: unknown field %q in build block", t.Line, t.Col, t.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := p.expect(TOKEN_RBRACE); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bc, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseAssignString() (string, error) {
|
||||
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
|
||||
return "", err
|
||||
}
|
||||
s, err := p.expect(TOKEN_STRING)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.Value, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseAssignStringList() ([]string, error) {
|
||||
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.parseStringList()
|
||||
}
|
||||
|
||||
func parseProfileIdent(v string, line, col int) (BuildProfile, error) {
|
||||
switch v {
|
||||
case "release":
|
||||
return ProfileRelease, nil
|
||||
case "debug":
|
||||
return ProfileDebug, nil
|
||||
case "sanitized":
|
||||
return ProfileSanitized, nil
|
||||
default:
|
||||
return ProfileUnset, fmt.Errorf("%d:%d: unknown profile %q (expected release/debug/sanitized)", line, col, v)
|
||||
}
|
||||
}
|
||||
|
||||
func parseWarningsIdent(v string, line, col int) (WarningLevel, error) {
|
||||
switch v {
|
||||
case "default":
|
||||
return WarningsDefault, nil
|
||||
case "strict":
|
||||
return WarningsStrict, nil
|
||||
case "pedantic":
|
||||
return WarningsPedantic, nil
|
||||
default:
|
||||
return WarningsUnset, fmt.Errorf("%d:%d: unknown warnings level %q (expected default/strict/pedantic)", line, col, v)
|
||||
}
|
||||
}
|
||||
|
||||
func validatePlatformList(xs []string, line, col int) error {
|
||||
for _, x := range xs {
|
||||
switch x {
|
||||
case "linux", "windows", "darwin":
|
||||
default:
|
||||
return fmt.Errorf("%d:%d: unknown platform %q (expected linux/windows/darwin)", line, col, x)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseToolchainsBlock() ([]*ToolchainSpec, error) {
|
||||
if _, err := p.expect(TOKEN_LBRACE); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var specs []*ToolchainSpec
|
||||
seen := map[string]bool{}
|
||||
|
||||
for !p.isRBrace() {
|
||||
nameTok := p.peek()
|
||||
if nameTok.Type != TOKEN_IDENT && nameTok.Type != TOKEN_STRING {
|
||||
return nil, fmt.Errorf("%d:%d: expected toolchain name, got %q", nameTok.Line, nameTok.Col, nameTok.Value)
|
||||
}
|
||||
p.advance()
|
||||
name := nameTok.Value
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("%d:%d: duplicate toolchain %q", nameTok.Line, nameTok.Col, name)
|
||||
}
|
||||
seen[name] = true
|
||||
|
||||
spec, err := p.parseToolchainEntry(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
specs = append(specs, spec)
|
||||
}
|
||||
|
||||
if _, err := p.expect(TOKEN_RBRACE); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return specs, nil
|
||||
}
|
||||
|
||||
func (p *Parser) parseToolchainEntry(name string) (*ToolchainSpec, error) {
|
||||
if _, err := p.expect(TOKEN_LBRACE); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
spec := &ToolchainSpec{Name: name}
|
||||
for !p.isRBrace() {
|
||||
t := p.peek()
|
||||
if t.Type != TOKEN_IDENT {
|
||||
return nil, fmt.Errorf("%d:%d: unexpected token %q in toolchain block", t.Line, t.Col, t.Value)
|
||||
}
|
||||
switch t.Value {
|
||||
case "platforms":
|
||||
p.advance()
|
||||
xs, err := p.parseAssignStringList()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePlatformList(xs, t.Line, t.Col); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec.Platforms = xs
|
||||
|
||||
case "binary":
|
||||
p.advance()
|
||||
s, err := p.parseAssignString()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spec.Binary = s
|
||||
|
||||
case "class":
|
||||
p.advance()
|
||||
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, err := p.expect(TOKEN_IDENT)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch id.Value {
|
||||
case "gnu", "msvc":
|
||||
default:
|
||||
return nil, fmt.Errorf("%d:%d: unknown compiler class %q (expected gnu/msvc)", id.Line, id.Col, id.Value)
|
||||
}
|
||||
spec.Class = id.Value
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("%d:%d: unknown field %q in toolchain block", t.Line, t.Col, t.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := p.expect(TOKEN_RBRACE); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(spec.Platforms) == 0 {
|
||||
return nil, fmt.Errorf("toolchain %q: platforms is required", name)
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
455
dsl/build_test.go
Normal file
455
dsl/build_test.go
Normal file
@@ -0,0 +1,455 @@
|
||||
package dsl
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseBuildBlockMinimal(t *testing.T) {
|
||||
src := `
|
||||
build_defaults {
|
||||
language = "c"
|
||||
standard = "c11"
|
||||
sources = "*.c"
|
||||
output = "solution"
|
||||
warnings = strict
|
||||
}
|
||||
|
||||
build "release" {
|
||||
profile = release
|
||||
}
|
||||
|
||||
build "debug" {
|
||||
profile = debug
|
||||
}
|
||||
|
||||
group("g1") {
|
||||
weight = 1.0
|
||||
test("t1") {
|
||||
stdout = "ok\n"
|
||||
}
|
||||
}
|
||||
`
|
||||
f, _, err := Parse(src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
|
||||
if f.BuildDefaults == nil {
|
||||
t.Fatal("BuildDefaults not populated")
|
||||
}
|
||||
if f.BuildDefaults.Language != "c" {
|
||||
t.Errorf("language = %q", f.BuildDefaults.Language)
|
||||
}
|
||||
if f.BuildDefaults.Standard != "c11" {
|
||||
t.Errorf("standard = %q", f.BuildDefaults.Standard)
|
||||
}
|
||||
if len(f.BuildDefaults.Sources) != 1 || f.BuildDefaults.Sources[0] != "*.c" {
|
||||
t.Errorf("sources = %v", f.BuildDefaults.Sources)
|
||||
}
|
||||
if f.BuildDefaults.Output != "solution" {
|
||||
t.Errorf("output = %q", f.BuildDefaults.Output)
|
||||
}
|
||||
if f.BuildDefaults.Warnings != WarningsStrict {
|
||||
t.Errorf("warnings = %v", f.BuildDefaults.Warnings)
|
||||
}
|
||||
|
||||
if len(f.Builds) != 2 {
|
||||
t.Fatalf("expected 2 builds, got %d", len(f.Builds))
|
||||
}
|
||||
if f.Builds[0].Name != "release" || f.Builds[0].Profile != ProfileRelease {
|
||||
t.Errorf("builds[0] = %+v", f.Builds[0])
|
||||
}
|
||||
if f.Builds[1].Name != "debug" || f.Builds[1].Profile != ProfileDebug {
|
||||
t.Errorf("builds[1] = %+v", f.Builds[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBuildLegacyStillWorks(t *testing.T) {
|
||||
src := `
|
||||
build "cc -O2 solution.c -o solution"
|
||||
timeout 5s
|
||||
|
||||
group("g1") {
|
||||
weight = 1.0
|
||||
test("t1") { stdout = "ok\n" }
|
||||
}
|
||||
`
|
||||
f, _, err := Parse(src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if f.Build != "cc -O2 solution.c -o solution" {
|
||||
t.Errorf("legacy build = %q", f.Build)
|
||||
}
|
||||
if f.BuildDefaults != nil || len(f.Builds) != 0 {
|
||||
t.Errorf("structured fields should be empty for legacy form")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBuildOSOverride(t *testing.T) {
|
||||
src := `
|
||||
build_defaults {
|
||||
language = "c"
|
||||
standard = "c11"
|
||||
sources = "*.c"
|
||||
output = "solution"
|
||||
}
|
||||
|
||||
build "release" {
|
||||
profile = release
|
||||
linux { extra = "-fPIC" }
|
||||
windows { extra = "/bigobj" }
|
||||
}
|
||||
|
||||
group("g1") {
|
||||
weight = 1.0
|
||||
test("t1") { stdout = "ok\n" }
|
||||
}
|
||||
`
|
||||
f, _, err := Parse(src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
b := f.Builds[0]
|
||||
if b.Linux == nil || len(b.Linux.Extra) != 1 || b.Linux.Extra[0] != "-fPIC" {
|
||||
t.Errorf("linux override = %+v", b.Linux)
|
||||
}
|
||||
if b.Windows == nil || len(b.Windows.Extra) != 1 || b.Windows.Extra[0] != "/bigobj" {
|
||||
t.Errorf("windows override = %+v", b.Windows)
|
||||
}
|
||||
if b.Darwin != nil {
|
||||
t.Errorf("darwin override should be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBuildPlatformsFilter(t *testing.T) {
|
||||
src := `
|
||||
build "sanitized" {
|
||||
profile = sanitized
|
||||
sanitize = "address" "undefined"
|
||||
platforms = "linux"
|
||||
compilers = "gcc" "clang"
|
||||
}
|
||||
|
||||
group("g1") {
|
||||
weight = 1.0
|
||||
test("t1") { stdout = "ok\n" }
|
||||
}
|
||||
`
|
||||
f, _, err := Parse(src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
b := f.Builds[0]
|
||||
if b.Profile != ProfileSanitized {
|
||||
t.Errorf("profile = %v", b.Profile)
|
||||
}
|
||||
if len(b.Sanitize) != 2 || b.Sanitize[0] != "address" || b.Sanitize[1] != "undefined" {
|
||||
t.Errorf("sanitize = %v", b.Sanitize)
|
||||
}
|
||||
if len(b.Platforms) != 1 || b.Platforms[0] != "linux" {
|
||||
t.Errorf("platforms = %v", b.Platforms)
|
||||
}
|
||||
if len(b.Compilers) != 2 {
|
||||
t.Errorf("compilers = %v", b.Compilers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBuildDefineField(t *testing.T) {
|
||||
src := `
|
||||
build "rel" {
|
||||
profile = release
|
||||
define("NDEBUG") = "1"
|
||||
define("VERSION") = "42"
|
||||
}
|
||||
|
||||
group("g1") {
|
||||
weight = 1.0
|
||||
test("t1") { stdout = "ok\n" }
|
||||
}
|
||||
`
|
||||
f, _, err := Parse(src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
d := f.Builds[0].Defines
|
||||
if d["NDEBUG"] != "1" || d["VERSION"] != "42" {
|
||||
t.Errorf("defines = %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBuildErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "unknown profile",
|
||||
src: `
|
||||
build "x" { profile = ultra }
|
||||
group("g") { weight=1.0 test("t"){ stdout="ok\n" } }
|
||||
`,
|
||||
want: "unknown profile",
|
||||
},
|
||||
{
|
||||
name: "unknown warnings",
|
||||
src: `
|
||||
build "x" { warnings = insane }
|
||||
group("g") { weight=1.0 test("t"){ stdout="ok\n" } }
|
||||
`,
|
||||
want: "unknown warnings",
|
||||
},
|
||||
{
|
||||
name: "unknown platform",
|
||||
src: `
|
||||
build "x" { platforms = "bsd" }
|
||||
group("g") { weight=1.0 test("t"){ stdout="ok\n" } }
|
||||
`,
|
||||
want: "unknown platform",
|
||||
},
|
||||
{
|
||||
name: "nested OS override",
|
||||
src: `
|
||||
build "x" {
|
||||
linux {
|
||||
windows { extra = "nope" }
|
||||
}
|
||||
}
|
||||
group("g") { weight=1.0 test("t"){ stdout="ok\n" } }
|
||||
`,
|
||||
want: "cannot be nested",
|
||||
},
|
||||
{
|
||||
name: "duplicate OS override",
|
||||
src: `
|
||||
build "x" {
|
||||
linux { extra = "-a" }
|
||||
linux { extra = "-b" }
|
||||
}
|
||||
group("g") { weight=1.0 test("t"){ stdout="ok\n" } }
|
||||
`,
|
||||
want: "duplicate linux override",
|
||||
},
|
||||
{
|
||||
name: "unknown field",
|
||||
src: `
|
||||
build "x" { magic = "yes" }
|
||||
group("g") { weight=1.0 test("t"){ stdout="ok\n" } }
|
||||
`,
|
||||
want: "unknown field",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, _, err := Parse(c.src)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBuildMixedLegacyAndStructuredRejected(t *testing.T) {
|
||||
src := `
|
||||
build "cc -O2 solution.c -o solution"
|
||||
build "release" { profile = release }
|
||||
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
|
||||
`
|
||||
_, _, err := Parse(src)
|
||||
if err == nil {
|
||||
t.Fatal("expected error mixing legacy and structured builds")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cannot mix") {
|
||||
t.Errorf("error %q does not mention mixing", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseBuildDuplicateNameRejected(t *testing.T) {
|
||||
src := `
|
||||
build "release" { profile = release }
|
||||
build "release" { profile = debug }
|
||||
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
|
||||
`
|
||||
_, _, err := Parse(src)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on duplicate build name")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate") {
|
||||
t.Errorf("error %q does not mention duplicate", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfigResolveMerge(t *testing.T) {
|
||||
defaults := &BuildConfig{
|
||||
Language: "c",
|
||||
Standard: "c11",
|
||||
Sources: []string{"*.c"},
|
||||
Output: "solution",
|
||||
Warnings: WarningsStrict,
|
||||
}
|
||||
b := &BuildConfig{
|
||||
Name: "release",
|
||||
Profile: ProfileRelease,
|
||||
Extra: []string{"-DFOO"},
|
||||
Linux: &BuildConfig{
|
||||
Extra: []string{"-fPIC"},
|
||||
},
|
||||
Windows: &BuildConfig{
|
||||
Extra: []string{"/bigobj"},
|
||||
Output: "solution",
|
||||
},
|
||||
}
|
||||
|
||||
linux := b.Resolve(defaults, "linux")
|
||||
if linux.Language != "c" || linux.Standard != "c11" {
|
||||
t.Errorf("linux defaults not merged: %+v", linux)
|
||||
}
|
||||
if linux.Profile != ProfileRelease {
|
||||
t.Errorf("linux profile = %v", linux.Profile)
|
||||
}
|
||||
if linux.Warnings != WarningsStrict {
|
||||
t.Errorf("linux warnings = %v", linux.Warnings)
|
||||
}
|
||||
if len(linux.Sources) != 1 || linux.Sources[0] != "*.c" {
|
||||
t.Errorf("linux sources = %v", linux.Sources)
|
||||
}
|
||||
if len(linux.Extra) != 2 || linux.Extra[0] != "-DFOO" || linux.Extra[1] != "-fPIC" {
|
||||
t.Errorf("linux extra = %v", linux.Extra)
|
||||
}
|
||||
|
||||
windows := b.Resolve(defaults, "windows")
|
||||
if len(windows.Extra) != 2 || windows.Extra[0] != "-DFOO" || windows.Extra[1] != "/bigobj" {
|
||||
t.Errorf("windows extra = %v", windows.Extra)
|
||||
}
|
||||
|
||||
darwin := b.Resolve(defaults, "darwin")
|
||||
if len(darwin.Extra) != 1 || darwin.Extra[0] != "-DFOO" {
|
||||
t.Errorf("darwin extra (no override) = %v", darwin.Extra)
|
||||
}
|
||||
|
||||
if len(b.Extra) != 1 {
|
||||
t.Errorf("receiver mutated: %v", b.Extra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolchainsBlock(t *testing.T) {
|
||||
src := `
|
||||
toolchains {
|
||||
gcc { platforms = "linux" }
|
||||
clang {
|
||||
platforms = "linux" "windows"
|
||||
binary = "clang-17"
|
||||
class = gnu
|
||||
}
|
||||
msvc { platforms = "windows" }
|
||||
nvcc {
|
||||
platforms = "linux"
|
||||
class = gnu
|
||||
}
|
||||
}
|
||||
|
||||
build "release" { profile = release }
|
||||
|
||||
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
|
||||
`
|
||||
f, _, err := Parse(src)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(f.Toolchains) != 4 {
|
||||
t.Fatalf("expected 4 toolchains, got %d", len(f.Toolchains))
|
||||
}
|
||||
gcc := f.Toolchains[0]
|
||||
if gcc.Name != "gcc" || len(gcc.Platforms) != 1 || gcc.Platforms[0] != "linux" {
|
||||
t.Errorf("gcc = %+v", gcc)
|
||||
}
|
||||
clang := f.Toolchains[1]
|
||||
if clang.Name != "clang" || len(clang.Platforms) != 2 || clang.Binary != "clang-17" || clang.Class != "gnu" {
|
||||
t.Errorf("clang = %+v", clang)
|
||||
}
|
||||
if clang.Platforms[0] != "linux" || clang.Platforms[1] != "windows" {
|
||||
t.Errorf("clang platforms = %v", clang.Platforms)
|
||||
}
|
||||
nvcc := f.Toolchains[3]
|
||||
if nvcc.Name != "nvcc" || len(nvcc.Platforms) != 1 || nvcc.Platforms[0] != "linux" || nvcc.Class != "gnu" {
|
||||
t.Errorf("nvcc = %+v", nvcc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseToolchainsErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
src string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"missing platforms",
|
||||
`toolchains { gcc { } }
|
||||
group("g") { weight=1.0 test("t") { stdout="ok\n" } }`,
|
||||
"platforms is required",
|
||||
},
|
||||
{
|
||||
"unknown platform",
|
||||
`toolchains { gcc { platforms = "bsd" } }
|
||||
group("g") { weight=1.0 test("t") { stdout="ok\n" } }`,
|
||||
"unknown platform",
|
||||
},
|
||||
{
|
||||
"unknown class",
|
||||
`toolchains { gcc { platforms = "linux" class = llvm } }
|
||||
group("g") { weight=1.0 test("t") { stdout="ok\n" } }`,
|
||||
"unknown compiler class",
|
||||
},
|
||||
{
|
||||
"duplicate toolchain",
|
||||
`toolchains {
|
||||
gcc { platforms = "linux" }
|
||||
gcc { platforms = "linux" }
|
||||
}
|
||||
group("g") { weight=1.0 test("t") { stdout="ok\n" } }`,
|
||||
"duplicate toolchain",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, _, err := Parse(c.src)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConfigAppliesTo(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
b BuildConfig
|
||||
os, cc string
|
||||
expected bool
|
||||
}{
|
||||
{"no filters", BuildConfig{}, "linux", "gcc", true},
|
||||
{"os match", BuildConfig{Platforms: []string{"linux"}}, "linux", "gcc", true},
|
||||
{"os mismatch", BuildConfig{Platforms: []string{"linux"}}, "windows", "msvc", false},
|
||||
{"cc match", BuildConfig{Compilers: []string{"gcc", "clang"}}, "linux", "clang", true},
|
||||
{"cc mismatch", BuildConfig{Compilers: []string{"gcc"}}, "linux", "msvc", false},
|
||||
{"both match", BuildConfig{Platforms: []string{"linux"}, Compilers: []string{"gcc"}}, "linux", "gcc", true},
|
||||
{"os ok cc bad", BuildConfig{Platforms: []string{"linux"}, Compilers: []string{"gcc"}}, "linux", "clang", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := c.b.AppliesTo(c.os, c.cc)
|
||||
if got != c.expected {
|
||||
t.Errorf("AppliesTo(%q, %q) = %v, want %v", c.os, c.cc, got, c.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -91,7 +91,31 @@ func (p *Parser) parseFile() (*File, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if p.peek().Type == TOKEN_LBRACE {
|
||||
bc, err := p.parseBuildBlock(s.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.Builds = append(f.Builds, bc)
|
||||
} else {
|
||||
f.Build = s.Value
|
||||
}
|
||||
|
||||
case "build_defaults":
|
||||
p.advance()
|
||||
bc, err := p.parseBuildBlock("")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.BuildDefaults = bc
|
||||
|
||||
case "toolchains":
|
||||
p.advance()
|
||||
specs, err := p.parseToolchainsBlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.Toolchains = specs
|
||||
|
||||
case "build_linux":
|
||||
p.advance()
|
||||
@@ -195,10 +219,34 @@ func (p *Parser) parseFile() (*File, error) {
|
||||
if err := p.validateWeights(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateBuilds(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func validateBuilds(f *File) error {
|
||||
hasLegacy := f.Build != "" || f.BuildLinux != "" || f.BuildWindows != "" || f.BuildDarwin != ""
|
||||
hasStructured := f.BuildDefaults != nil || len(f.Builds) > 0
|
||||
|
||||
if hasLegacy && hasStructured {
|
||||
return fmt.Errorf("cannot mix legacy `build \"shell\"` with structured `build \"name\" { ... }` in the same suite")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, b := range f.Builds {
|
||||
if b.Name == "" {
|
||||
return fmt.Errorf("structured build must have a name")
|
||||
}
|
||||
if seen[b.Name] {
|
||||
return fmt.Errorf("duplicate build name %q", b.Name)
|
||||
}
|
||||
seen[b.Name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Parser) validateWeights(f *File) error {
|
||||
if len(f.Groups) == 0 {
|
||||
return nil
|
||||
@@ -715,8 +763,6 @@ func (p *Parser) parseInt() (int, error) {
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// parseSize accepts either a TOKEN_SIZE (e.g. "256MB", "1GiB", "512K") or a bare
|
||||
// TOKEN_INT interpreted as bytes. MiB/MB are both 1024² — we use IEC semantics.
|
||||
func (p *Parser) parseSize() (int64, error) {
|
||||
t := p.peek()
|
||||
switch t.Type {
|
||||
|
||||
19
example/c-sum-v2/solution.c
Normal file
19
example/c-sum-v2/solution.c
Normal file
@@ -0,0 +1,19 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
int main(void) {
|
||||
long n;
|
||||
if (scanf("%ld", &n) != 1) {
|
||||
return 1;
|
||||
}
|
||||
long long sum = 0;
|
||||
for (long i = 0; i < n; i++) {
|
||||
long long x;
|
||||
if (scanf("%lld", &x) != 1) {
|
||||
return 1;
|
||||
}
|
||||
sum += x;
|
||||
}
|
||||
printf("%lld\n", sum);
|
||||
return 0;
|
||||
}
|
||||
82
example/c-sum-v2/sum.jdg
Normal file
82
example/c-sum-v2/sum.jdg
Normal file
@@ -0,0 +1,82 @@
|
||||
// Smoke test for the structured build DSL. Same task as example/c-sum
|
||||
// (sum of N integers) but described declaratively — no shell strings,
|
||||
// no per-platform `if /I "%CC%"=="msvc"` branching. The compiler is
|
||||
// selected via the JUDGE_CC env variable set by the CI matrix.
|
||||
|
||||
toolchains {
|
||||
gcc { platforms = "linux" }
|
||||
clang { platforms = "linux" "windows" }
|
||||
msvc { platforms = "windows" }
|
||||
}
|
||||
|
||||
build_defaults {
|
||||
language = "c"
|
||||
standard = "c11"
|
||||
sources = "solution.c"
|
||||
output = "solution"
|
||||
warnings = strict
|
||||
}
|
||||
|
||||
build "release" {
|
||||
profile = release
|
||||
}
|
||||
|
||||
build "debug" {
|
||||
profile = debug
|
||||
}
|
||||
|
||||
build "sanitized" {
|
||||
profile = sanitized
|
||||
sanitize = "address" "undefined"
|
||||
platforms = "linux"
|
||||
compilers = "gcc" "clang"
|
||||
}
|
||||
|
||||
build "debug-valgrind" {
|
||||
profile = debug
|
||||
wrapper = "valgrind --error-exitcode=99 --leak-check=full -q"
|
||||
platforms = "linux"
|
||||
compilers = "gcc"
|
||||
}
|
||||
|
||||
timeout 5s
|
||||
normalize_crlf = true
|
||||
trim_trailing_ws = true
|
||||
|
||||
group("basic") {
|
||||
weight = 0.5
|
||||
|
||||
test("one number") {
|
||||
stdin = "1\n42\n"
|
||||
stdout = "42\n"
|
||||
}
|
||||
|
||||
test("three numbers") {
|
||||
stdin = "3\n1 2 3\n"
|
||||
stdout = "6\n"
|
||||
}
|
||||
|
||||
test("negatives") {
|
||||
stdin = "4\n-1 -2 3 5\n"
|
||||
stdout = "5\n"
|
||||
}
|
||||
|
||||
test("zero count") {
|
||||
stdin = "0\n"
|
||||
stdout = "0\n"
|
||||
}
|
||||
}
|
||||
|
||||
group("edge") {
|
||||
weight = 0.5
|
||||
|
||||
test("large sum fits in int64") {
|
||||
stdin = "3\n2000000000 2000000000 2000000000\n"
|
||||
stdout = "6000000000\n"
|
||||
}
|
||||
|
||||
test("multiline input") {
|
||||
stdin = "5\n10\n20\n30\n40\n50\n"
|
||||
stdout = "150\n"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -13,11 +14,47 @@ import (
|
||||
)
|
||||
|
||||
func Text(w io.Writer, result *runner.SuiteResult) {
|
||||
if result.BuildLog != "" {
|
||||
fmt.Fprintf(w, "=== BUILD LOG ===\n%s\n", result.BuildLog)
|
||||
if len(result.Builds) == 0 {
|
||||
fmt.Fprintln(w, "(no builds executed)")
|
||||
return
|
||||
}
|
||||
|
||||
for _, gr := range result.Groups {
|
||||
multi := len(result.Builds) > 1
|
||||
if multi {
|
||||
fmt.Fprintf(w, "=== %d builds ===\n", len(result.Builds))
|
||||
for _, b := range result.Builds {
|
||||
marker := ""
|
||||
if b.Skipped {
|
||||
marker = " (skipped)"
|
||||
}
|
||||
fmt.Fprintf(w, " • %s%s score=%.4f\n", b.Name, marker, b.TotalScore)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
|
||||
for _, b := range result.Builds {
|
||||
if multi {
|
||||
fmt.Fprintf(w, "======== build %q ========\n", b.Name)
|
||||
}
|
||||
writeBuildText(w, b, multi)
|
||||
}
|
||||
|
||||
if multi {
|
||||
fmt.Fprintf(w, "\n══ AGGREGATE SCORE (min across builds): %.4f / 1.0000 ══\n", result.TotalScore)
|
||||
}
|
||||
}
|
||||
|
||||
func writeBuildText(w io.Writer, b *runner.BuildRun, multi bool) {
|
||||
if b.Skipped {
|
||||
fmt.Fprintf(w, "skipped: %s\n", b.SkipReason)
|
||||
return
|
||||
}
|
||||
|
||||
if b.BuildLog != "" {
|
||||
fmt.Fprintf(w, "=== BUILD LOG ===\n%s\n", b.BuildLog)
|
||||
}
|
||||
|
||||
for _, gr := range b.Groups {
|
||||
passed := gr.Passed
|
||||
total := gr.Total
|
||||
pct := 0.0
|
||||
@@ -53,13 +90,19 @@ func Text(w io.Writer, result *runner.SuiteResult) {
|
||||
fmt.Fprintf(w, "└─\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "\n══ TOTAL SCORE: %.4f / 1.0000 ══\n", result.TotalScore)
|
||||
if !multi {
|
||||
fmt.Fprintf(w, "\n══ TOTAL SCORE: %.4f / 1.0000 ══\n", b.TotalScore)
|
||||
}
|
||||
}
|
||||
|
||||
func JSON(w io.Writer, result *runner.SuiteResult) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(jsonResult(result))
|
||||
|
||||
if len(result.Builds) <= 1 {
|
||||
return enc.Encode(flatResult(result))
|
||||
}
|
||||
return enc.Encode(nestedResult(result))
|
||||
}
|
||||
|
||||
type jsonSuiteResult struct {
|
||||
@@ -68,6 +111,21 @@ type jsonSuiteResult struct {
|
||||
Groups []jsonGroupResult `json:"groups"`
|
||||
}
|
||||
|
||||
type jsonMultiSuiteResult struct {
|
||||
TotalScore float64 `json:"total_score"`
|
||||
Builds map[string]jsonSuiteBuild `json:"builds"`
|
||||
}
|
||||
|
||||
type jsonSuiteBuild struct {
|
||||
Name string `json:"name"`
|
||||
Toolchain string `json:"toolchain,omitempty"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
SkipReason string `json:"skip_reason,omitempty"`
|
||||
TotalScore float64 `json:"total_score"`
|
||||
BuildLog string `json:"build_log,omitempty"`
|
||||
Groups []jsonGroupResult `json:"groups"`
|
||||
}
|
||||
|
||||
type jsonGroupResult struct {
|
||||
Name string `json:"name"`
|
||||
Weight float64 `json:"weight"`
|
||||
@@ -86,6 +144,62 @@ type jsonTestResult struct {
|
||||
Failures []string `json:"failures,omitempty"`
|
||||
}
|
||||
|
||||
func flatResult(r *runner.SuiteResult) jsonSuiteResult {
|
||||
res := jsonSuiteResult{TotalScore: r.TotalScore}
|
||||
if len(r.Builds) == 0 {
|
||||
return res
|
||||
}
|
||||
b := r.Builds[0]
|
||||
res.BuildLog = b.BuildLog
|
||||
for _, gr := range b.Groups {
|
||||
res.Groups = append(res.Groups, groupJSON(gr))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func nestedResult(r *runner.SuiteResult) jsonMultiSuiteResult {
|
||||
res := jsonMultiSuiteResult{
|
||||
TotalScore: r.TotalScore,
|
||||
Builds: map[string]jsonSuiteBuild{},
|
||||
}
|
||||
for _, b := range r.Builds {
|
||||
entry := jsonSuiteBuild{
|
||||
Name: b.Name,
|
||||
Toolchain: b.Toolchain,
|
||||
Skipped: b.Skipped,
|
||||
SkipReason: b.SkipReason,
|
||||
TotalScore: b.TotalScore,
|
||||
BuildLog: b.BuildLog,
|
||||
}
|
||||
for _, gr := range b.Groups {
|
||||
entry.Groups = append(entry.Groups, groupJSON(gr))
|
||||
}
|
||||
res.Builds[b.Name] = entry
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func groupJSON(gr *runner.GroupResult) jsonGroupResult {
|
||||
jgr := jsonGroupResult{
|
||||
Name: gr.Name,
|
||||
Weight: gr.Weight,
|
||||
Score: gr.Score,
|
||||
Passed: gr.Passed,
|
||||
Total: gr.Total,
|
||||
}
|
||||
for _, tr := range gr.Tests {
|
||||
jgr.Tests = append(jgr.Tests, jsonTestResult{
|
||||
Name: tr.Name,
|
||||
Status: tr.Status.String(),
|
||||
ElapsedMs: tr.Elapsed.Milliseconds(),
|
||||
PeakMemoryKB: tr.PeakMemory / 1024,
|
||||
MemoryLimitKB: tr.MemoryLimit / 1024,
|
||||
Failures: tr.Failures,
|
||||
})
|
||||
}
|
||||
return jgr
|
||||
}
|
||||
|
||||
func Aggregate(w io.Writer, dir string) error {
|
||||
files, err := filepath.Glob(filepath.Join(dir, "*", "*.json"))
|
||||
if err != nil {
|
||||
@@ -105,24 +219,24 @@ func Aggregate(w io.Writer, dir string) error {
|
||||
}
|
||||
|
||||
var entries []entry
|
||||
allPassed := true
|
||||
minScore := math.Inf(1)
|
||||
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", f, err)
|
||||
}
|
||||
var report jsonSuiteResult
|
||||
if err := json.Unmarshal(data, &report); err != nil {
|
||||
score, err := extractTotalScore(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", f, err)
|
||||
}
|
||||
|
||||
cfg := filepath.Base(filepath.Dir(f))
|
||||
cfg = strings.TrimPrefix(cfg, "report_")
|
||||
|
||||
entries = append(entries, entry{Config: cfg, Score: report.TotalScore})
|
||||
if report.TotalScore < 0.9999 {
|
||||
allPassed = false
|
||||
entries = append(entries, entry{Config: cfg, Score: score})
|
||||
if score < minScore {
|
||||
minScore = score
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,13 +247,24 @@ func Aggregate(w io.Writer, dir string) error {
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(w, "| %s | %.4f |\n", e.Config, e.Score)
|
||||
}
|
||||
fmt.Fprintf(w, "| **Overall (min)** | **%.4f** |\n", minScore)
|
||||
|
||||
if !allPassed {
|
||||
return fmt.Errorf("one or more configurations scored below 1.0")
|
||||
if minScore < 0.9999 {
|
||||
return fmt.Errorf("minimum score across configurations is %.4f (below 1.0)", minScore)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTotalScore(data []byte) (float64, error) {
|
||||
var header struct {
|
||||
TotalScore float64 `json:"total_score"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &header); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return header.TotalScore, nil
|
||||
}
|
||||
|
||||
func humanBytes(n int64) string {
|
||||
const (
|
||||
KiB = 1024
|
||||
@@ -157,31 +282,3 @@ func humanBytes(n int64) string {
|
||||
return fmt.Sprintf("%dB", n)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonResult(r *runner.SuiteResult) jsonSuiteResult {
|
||||
res := jsonSuiteResult{
|
||||
TotalScore: r.TotalScore,
|
||||
BuildLog: r.BuildLog,
|
||||
}
|
||||
for _, gr := range r.Groups {
|
||||
jgr := jsonGroupResult{
|
||||
Name: gr.Name,
|
||||
Weight: gr.Weight,
|
||||
Score: gr.Score,
|
||||
Passed: gr.Passed,
|
||||
Total: gr.Total,
|
||||
}
|
||||
for _, tr := range gr.Tests {
|
||||
jgr.Tests = append(jgr.Tests, jsonTestResult{
|
||||
Name: tr.Name,
|
||||
Status: tr.Status.String(),
|
||||
ElapsedMs: tr.Elapsed.Milliseconds(),
|
||||
PeakMemoryKB: tr.PeakMemory / 1024,
|
||||
MemoryLimitKB: tr.MemoryLimit / 1024,
|
||||
Failures: tr.Failures,
|
||||
})
|
||||
}
|
||||
res.Groups = append(res.Groups, jgr)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
198
runner/compiler.go
Normal file
198
runner/compiler.go
Normal 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
293
runner/compiler_test.go
Normal 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
|
||||
}
|
||||
@@ -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
57
runner/result_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
314
runner/runner.go
314
runner/runner.go
@@ -21,6 +21,8 @@ type Config struct {
|
||||
WorkDir string
|
||||
BinaryName string
|
||||
Wrapper string
|
||||
|
||||
TargetBuild string
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
@@ -63,16 +65,130 @@ func (r *Runner) Run() *SuiteResult {
|
||||
|
||||
result := &SuiteResult{}
|
||||
|
||||
buildLog, err := r.build()
|
||||
result.BuildLog = buildLog
|
||||
if len(r.file.Builds) == 0 {
|
||||
run := r.runLegacyBuild()
|
||||
result.Builds = append(result.Builds, run)
|
||||
} else {
|
||||
result.Builds = r.runStructuredBuilds()
|
||||
}
|
||||
|
||||
result.TotalScore = result.AggregateScore()
|
||||
return result
|
||||
}
|
||||
|
||||
// runLegacyBuild handles the classic `build "shell-string"` form. It produces
|
||||
// a single BuildRun named "default" so that downstream consumers always see
|
||||
// the same shape.
|
||||
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
|
||||
}
|
||||
|
||||
// runStructuredBuilds handles the new DSL form with one or more named builds.
|
||||
// Each build is resolved against the current OS and toolchain, compiled via
|
||||
// the structured translator, and then exercised against every group.
|
||||
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 {
|
||||
// User asked for a different build. Don't include this one
|
||||
// in the result at all — discovery via --list-builds is the
|
||||
// caller's responsibility.
|
||||
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 runs
|
||||
}
|
||||
|
||||
// runGroups exercises every group/test in the suite against the currently
|
||||
// selected binary (r.binary) and records the outcome into run.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// fillBuildError populates a BuildRun with one failing synthetic test per
|
||||
// group when the build itself failed. This keeps the reported totals at 0
|
||||
// and matches the legacy behaviour.
|
||||
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
|
||||
@@ -84,20 +200,136 @@ func (r *Runner) Run() *SuiteResult {
|
||||
Status: StatusBuildError,
|
||||
})
|
||||
}
|
||||
result.Groups = append(result.Groups, gr)
|
||||
out = append(out, gr)
|
||||
}
|
||||
return result
|
||||
return out
|
||||
}
|
||||
|
||||
r.binary = resolveBinary(r.cfg.WorkDir, filepath.Base(r.binary))
|
||||
// legacyBuild runs the free-form shell build command. Kept under the old
|
||||
// name so reviewers can diff against the previous implementation easily.
|
||||
func (r *Runner) legacyBuild() (string, error) {
|
||||
buildCmd := r.buildCommand()
|
||||
|
||||
for _, g := range r.file.Groups {
|
||||
gr := r.runGroup(g)
|
||||
result.Groups = append(result.Groups, gr)
|
||||
result.TotalScore += gr.Score
|
||||
sources, err := r.findSources()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if sources != "" {
|
||||
buildCmd = strings.ReplaceAll(buildCmd, "$SOURCES", sources)
|
||||
}
|
||||
|
||||
return result
|
||||
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
|
||||
}
|
||||
|
||||
// compileStructured compiles one structured build via the translator and
|
||||
// returns the build log plus the absolute path to the produced binary.
|
||||
// The compiler is invoked via exec.Command directly — no shell involved.
|
||||
func (r *Runner) compileStructured(name string, cfg dsl.BuildConfig, tc Toolchain) (string, string, error) {
|
||||
// Expand source globs against the work dir.
|
||||
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
|
||||
|
||||
// Decide output path: <workdir>/build/<name>/<output>[.exe].
|
||||
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
|
||||
}
|
||||
|
||||
// expandSources expands each glob in patterns against workDir and returns
|
||||
// a slice of paths relative to workDir. Globs that match no files cause an
|
||||
// error — silent zero matches are almost always a typo.
|
||||
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 {
|
||||
// Fall back to treating it as a literal path.
|
||||
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 +384,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 +590,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 {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user