20 Commits

Author SHA1 Message Date
c023831222 add to release flow darwin/amd64
Some checks failed
go-test / go test (push) Successful in 21s
Release / Build & publish (push) Failing after 28s
2026-04-18 09:55:18 +03:00
7f14f8c236 test: expand dsl/runner coverage and add go-test CI workflow
All checks were successful
go-test / go test (push) Successful in 56s
- Add dsl/matcher_test.go covering ExactMatcher, ContainsMatcher,
    RegexMatcher, NumericEpsMatcher, AnyOrderMatcher and NoMatcher —
    previously 0% — including epsilon, count mismatch, unparsable
    numbers, invalid regex, and splitLines edge cases.
  - Add dsl/ast_test.go for the new Test.SetInputFile / SetStdin /
    SetOutputFile / SetStdout helpers and Pattern.IsDirMode.
  - Add dsl/build_string_test.go covering BuildProfile.String,
    WarningLevel.String and BuildConfig.MergeFrom (wrapper, defines
    into nil map, defines override existing, nil src).
  - Add dsl/merge_test.go driving mergeFiles to 100%: legacy build
    fields, duplicate toolchain/build/group from include, binary /
    sources / normalize_crlf / trim_trailing_ws propagation, local
    overrides of timeout and memory_limit.
  - Add dsl/parser_features_test.go for parseTest / parseGroup happy
    paths that were missing: file/outFile, env, wrapper, per-test
    timeout/memory overrides, non-zero exitCode, scoring partial /
    all_or_none and unknown scoring.
  - Add dsl/parser_errors_test.go, a 54-case table-driven test that
    hits every `expect(...)` error branch in parseGroup and parseTest
    (missing LPAREN/RPAREN/LBRACE/RBRACE/ASSIGN, wrong token types on
    weight/timeout/memory_limit/scoring/env/wrapper/file/outFile, and
    unclosed blocks).
  - Add dsl/parser_misc_test.go covering parsePattern dir-mode with
    args, unknown pattern field, non-ident in pattern, top-level
    binary / sources / normalize_crlf / trim_trailing_ws / bare-int
    memory_limit, parseBool invalid ident and non-ident, matcher
    without an operator, validateBuilds legacy+structured conflict.
  - Add dsl/build_parser_test.go covering every BuildConfig field
    (sources, includes, sanitize, link, extra, platforms, compilers,
    defines), OS overrides on named builds, nested / duplicate OS
    override errors, unknown build / profile / warnings / platform,
    missing = on assign-string and assign-string-list, define(...)
    error cases, and parseToolchainsBlock (duplicate name, missing
    platforms, bad name token, unknown field, unknown compiler class,
    binary and class propagation).
  - Add dsl/lexer_test.go for Token.String, TokenType.String UNKNOWN
    branch, line comments, unterminated string and heredoc, unknown
    escape sequence, escape decoding, unexpected character, every
    K/M/G/KiB/MiB/GiB size suffix, ms/s/m duration suffixes, negative
    integer lexing and float literals.
  - Extend runner/result_test.go with Status.String and
    TestResult.addFailure (both previously 0%).
  - Add .gitea/workflows/go-test.yml running `go vet` and
    `go test -race -coverprofile=coverage.out ./...` on push,
    pull_request and manual dispatch, uploading coverage.out as an
    artifact.

  Coverage: dsl 60.5% -> 85%+, runner 29.0% -> 30.5%.
2026-04-16 00:59:09 +03:00
5e0effc6fe refactor: extract expander helpers and Test stdio/file setters
- Add globWithAffixes in runner/expander.go that wraps filepath.Glob
    with an empty-match check and returns the computed prefix/suffix
    from splitGlob, collapsing three near-identical lookup blocks in
    expandGlobPattern into single calls.
  - Extract per-case Test construction from buildTests into a new
    buildTest helper so the loop body is a single call and the read /
    assemble / arg-template logic lives in one place.
  - Add Test.SetInputFile, Test.SetStdin, Test.SetOutputFile and
    Test.SetStdout methods on dsl.Test to encapsulate the stdin-vs-
    InFiles and stdout-vs-OutFiles wiring that buildTest previously
    did inline.
  - Adopt the `for range N` loop form in the determinism check in
    runner/compiler_test.go.
  - Switch the MSVC release test to expect /std:c17 since MSVC does
    not ship a c11 mode (worth surfacing a warning about this later).
2026-04-16 00:28:30 +03:00
c85c65ed49 refactor: modernize stdlib usage and move matchers into dsl
All checks were successful
build-dsl-smoke / Build judge (push) Successful in 16s
build-dsl-smoke / debug / clang / linux (push) Successful in 6s
build-dsl-smoke / debug / gcc / linux (push) Successful in 7s
build-dsl-smoke / release / clang / linux (push) Successful in 8s
build-dsl-smoke / release / gcc / linux (push) Successful in 9s
build-dsl-smoke / sanitized / gcc / linux (push) Successful in 6s
build-dsl-smoke / sanitized / clang / linux (push) Successful in 9s
build-dsl-smoke / debug-valgrind / gcc / linux (push) Successful in 15s
build-dsl-smoke / debug / clang / windows (push) Successful in 15s
build-dsl-smoke / debug / msvc / windows (push) Successful in 19s
build-dsl-smoke / release / clang / windows (push) Successful in 18s
build-dsl-smoke / release / msvc / windows (push) Successful in 18s
build-dsl-smoke / SUMMARY (push) Successful in 4s
- Move Matcher types and matching logic from runner/matcher.go into
  the dsl package as methods on the Matcher types. Runner now calls
  t.Stdout.Match(label, actual) instead of type-switching via a
  package-level applyMatcher helper.
- Replace custom contains/containsString helpers with slices.Contains
  in dsl/build.go and runner/compiler.go.
- Use maps.Copy instead of manual map copy in BuildConfig.MergeFrom.
- Adopt strings.SplitSeq, strings.CutPrefix and the `for range N` loop
  form in runner/limiter_linux.go.
- Ignore example/imdb build artifact.
2026-04-15 21:24:11 +03:00
52202abb53 fix compile standard for msvc
All checks were successful
build-dsl-smoke / Build judge (push) Successful in 13s
build-dsl-smoke / debug / clang / linux (push) Successful in 6s
build-dsl-smoke / debug / gcc / linux (push) Successful in 9s
build-dsl-smoke / release / clang / linux (push) Successful in 9s
build-dsl-smoke / release / gcc / linux (push) Successful in 9s
build-dsl-smoke / sanitized / clang / linux (push) Successful in 10s
build-dsl-smoke / sanitized / gcc / linux (push) Successful in 9s
build-dsl-smoke / debug / clang / windows (push) Successful in 18s
build-dsl-smoke / debug-valgrind / gcc / linux (push) Successful in 18s
build-dsl-smoke / debug / msvc / windows (push) Successful in 20s
build-dsl-smoke / release / clang / windows (push) Successful in 16s
build-dsl-smoke / release / msvc / windows (push) Successful in 17s
build-dsl-smoke / SUMMARY (push) Successful in 4s
Release / Build & publish (push) Successful in 49s
2026-04-15 11:02:05 +03:00
ea2ca49f0f fix jdg
All checks were successful
build-dsl-smoke / Build judge (push) Successful in 13s
build-dsl-smoke / debug / clang / linux (push) Successful in 6s
build-dsl-smoke / debug / gcc / linux (push) Successful in 7s
build-dsl-smoke / release / clang / linux (push) Successful in 10s
build-dsl-smoke / release / gcc / linux (push) Successful in 9s
build-dsl-smoke / sanitized / clang / linux (push) Successful in 9s
build-dsl-smoke / sanitized / gcc / linux (push) Successful in 7s
build-dsl-smoke / debug-valgrind / gcc / linux (push) Successful in 16s
Release / Build & publish (push) Successful in 44s
build-dsl-smoke / debug / clang / windows (push) Successful in 34s
build-dsl-smoke / release / clang / windows (push) Successful in 31s
build-dsl-smoke / debug / msvc / windows (push) Successful in 36s
build-dsl-smoke / release / msvc / windows (push) Successful in 33s
build-dsl-smoke / SUMMARY (push) Successful in 5s
2026-04-13 13:13:20 +03:00
7ec3a43c7a 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
2026-04-12 07:59:38 +00:00
358e3146bc use powershell instead of pwsh
All checks were successful
memory-limit / Build judge (push) Successful in 13s
memory-limit / Linux / clang (push) Successful in 10s
memory-limit / Linux / gcc (push) Successful in 10s
memory-limit / Windows / clang (push) Successful in 11s
memory-limit / Windows / msvc (push) Successful in 13s
2026-04-11 00:00:13 +03:00
5197e8d5ec fix jq not found
Some checks failed
memory-limit / Build judge (push) Successful in 11s
memory-limit / Linux / clang (push) Successful in 9s
memory-limit / Linux / gcc (push) Successful in 10s
memory-limit / Windows / clang (push) Failing after 11s
memory-limit / Windows / msvc (push) Failing after 13s
2026-04-10 23:58:48 +03:00
329a7eb132 fix windows memory limiter: instead of malloc return null make job object IO completion port
Some checks failed
memory-limit / Build judge (push) Successful in 11s
memory-limit / Linux / clang (push) Successful in 9s
memory-limit / Linux / gcc (push) Successful in 9s
memory-limit / Windows / clang (push) Failing after 11s
memory-limit / Windows / msvc (push) Failing after 13s
2026-04-10 23:56:11 +03:00
319ac8f73d fix swap linux cgroups
Some checks failed
memory-limit / Build judge (push) Successful in 13s
memory-limit / Linux / gcc (push) Successful in 9s
memory-limit / Linux / clang (push) Successful in 10s
memory-limit / Windows / clang (push) Failing after 11s
memory-limit / Windows / msvc (push) Failing after 13s
2026-04-10 23:52:09 +03:00
a8adbfbae1 try to fix root ownership
Some checks failed
memory-limit / Build judge (push) Successful in 13s
memory-limit / Linux / clang (push) Failing after 10s
memory-limit / Linux / gcc (push) Failing after 10s
memory-limit / Windows / clang (push) Failing after 13s
memory-limit / Windows / msvc (push) Failing after 13s
2026-04-10 23:49:59 +03:00
ca56aa5bc4 fix stupid bug
Some checks failed
memory-limit / Build judge (push) Successful in 17s
memory-limit / Linux / clang (push) Failing after 9s
memory-limit / Linux / gcc (push) Failing after 10s
memory-limit / Windows / clang (push) Failing after 11s
memory-limit / Windows / msvc (push) Failing after 14s
2026-04-10 23:43:05 +03:00
e9f07dc47b test memory limit
Some checks failed
memory-limit / Build judge (push) Successful in 18s
memory-limit / Linux / gcc (push) Failing after 14s
memory-limit / Linux / clang (push) Failing after 15s
memory-limit / Windows / clang (push) Failing after 16s
memory-limit / Windows / msvc (push) Failing after 17s
2026-04-10 23:41:37 +03:00
a977d4d9f5 add memory limit 2026-04-10 18:45:40 +03:00
86b8d83643 fix license
All checks were successful
Release / Build & publish (push) Successful in 33s
2026-04-07 09:57:55 +03:00
8aea2806e0 fix license
Some checks failed
Release / Build & publish (push) Failing after 21s
2026-04-07 09:56:30 +03:00
edfdc40aea fix repo miss vsix
All checks were successful
Release / Build & publish (push) Successful in 1m35s
2026-04-07 09:54:00 +03:00
e4ab003a9d add mac, fix vsix release
All checks were successful
Release / Build & publish (push) Successful in 37s
2026-04-07 09:51:53 +03:00
00e1c9195c add sources, add visx to release flow
All checks were successful
Release / Build & publish (push) Successful in 1m26s
2026-04-06 19:50:16 +03:00
75 changed files with 17262 additions and 328 deletions

View File

@@ -0,0 +1,144 @@
name: build-dsl-smoke
run-name: "Structured build DSL smoke test"
on:
workflow_dispatch:
env:
SUITE_FILE: sum.jdg
EXAMPLE_DIR: example/c-sum-v2
jobs:
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: build_judge
name: "${{ matrix.build }} / ${{ matrix.toolchain }} / ${{ matrix.platform }}"
strategy:
fail-fast: false
matrix:
include:
- { build: release, toolchain: gcc, platform: linux }
- { build: release, toolchain: clang, platform: linux }
- { build: release, toolchain: clang, platform: windows }
- { build: release, toolchain: msvc, platform: windows }
- { build: debug, toolchain: gcc, platform: linux }
- { build: debug, toolchain: clang, platform: linux }
- { build: debug, toolchain: clang, platform: windows }
- { build: debug, toolchain: msvc, platform: windows }
- { build: sanitized, toolchain: gcc, platform: linux }
- { build: sanitized, toolchain: clang, platform: linux }
- { build: debug-valgrind, toolchain: gcc, platform: linux }
runs-on: ${{ matrix.platform == 'windows' && 'Windows-Runner' || 'Linux-Runner' }}
timeout-minutes: 10
env:
REPORT_NAME: "report_${{ matrix.build }}_${{ matrix.toolchain }}_${{ matrix.platform }}"
JUDGE_TOOLCHAIN: ${{ matrix.toolchain }}
steps:
- uses: actions/checkout@v4
- name: Set up MSVC environment
if: matrix.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.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.build }} --json > "$GITHUB_WORKSPACE/${REPORT_NAME}.json" || true
cat "$GITHUB_WORKSPACE/${REPORT_NAME}.json"
judge "$SUITE_FILE" . --build=${{ matrix.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

View File

@@ -0,0 +1,36 @@
name: go-test
run-name: "Go unit tests"
on:
push:
pull_request:
workflow_dispatch:
jobs:
test:
name: go test
runs-on: Linux-Runner
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
- name: go vet
run: go vet ./...
- name: go test
run: go test -race -coverprofile=coverage.out ./...
- name: Coverage summary
run: go tool cover -func=coverage.out | tail -20
- name: Upload coverage
if: ${{ always() }}
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
with:
name: coverage
path: coverage.out
retention-days: 7

162
.gitea/workflows/memory.yml Normal file
View File

@@ -0,0 +1,162 @@
name: memory-limit
run-name: "memory_limit enforcement check"
on:
workflow_dispatch:
env:
SUITE_FILE: mem.jdg
EXAMPLE_DIR: example/mem-limit
jobs:
build_judge:
name: Build judge
runs-on: Linux-Runner
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Set up Go
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
- name: Upload judge binaries
uses: https://github.com/christopherHX/gitea-upload-artifact@v4
with:
name: judge-bin-mem
path: dist/
retention-days: 1
check:
needs: build_judge
name: "${{ matrix.toolchain.system }} / ${{ matrix.toolchain.use_compiler }}"
strategy:
fail-fast: false
matrix:
toolchain:
- { system: Linux, use_compiler: gcc }
- { system: Linux, use_compiler: clang }
- { system: Windows, use_compiler: clang }
- { system: Windows, use_compiler: msvc }
runs-on: ${{ matrix.toolchain.system }}-Runner
timeout-minutes: 10
env:
CC: ${{ matrix.toolchain.use_compiler }}
steps:
- name: Checkout judge harness
uses: actions/checkout@v4
- name: Set up MSVC environment
if: matrix.toolchain.use_compiler == 'msvc'
uses: ilammy/msvc-dev-cmd@v1
- name: Download judge binary
uses: https://github.com/christopherHX/gitea-download-artifact@v4
with:
name: judge-bin-mem
path: judge-bin
- name: Install judge on PATH
shell: bash
run: |
mkdir -p bin
if [ "${{ matrix.toolchain.system }}" = "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: Install jq (Linux)
if: matrix.toolchain.system == 'Linux'
run: sudo apt-get update && sudo apt-get install -y jq
- name: Run judge and capture JSON
shell: bash
working-directory: ${{ env.EXAMPLE_DIR }}
run: |
# `|| true` — the "exceeds_limit" group is *expected* to have a
# failing test, so judge will exit non-zero.
judge "$SUITE_FILE" . --json > report.json || true
cat report.json
- name: Assert enforcement (Linux)
if: matrix.toolchain.system == 'Linux'
shell: bash
working-directory: ${{ env.EXAMPLE_DIR }}
run: |
set -euo pipefail
pass_status=$(jq -r '.groups[] | select(.name=="within_limit") | .tests[] | select(.name=="allocate_16mb") | .status' report.json)
fail_status=$(jq -r '.groups[] | select(.name=="exceeds_limit") | .tests[] | select(.name=="allocate_256mb") | .status' report.json)
pass_peak=$(jq -r '.groups[] | select(.name=="within_limit") | .tests[] | select(.name=="allocate_16mb") | .peak_memory_kb // 0' report.json)
fail_peak=$(jq -r '.groups[] | select(.name=="exceeds_limit") | .tests[] | select(.name=="allocate_256mb") | .peak_memory_kb // 0' report.json)
echo "within_limit/allocate_16mb: status=$pass_status peak=${pass_peak} KiB"
echo "exceeds_limit/allocate_256mb: status=$fail_status peak=${fail_peak} KiB"
rc=0
if [ "$pass_status" != "PASS" ]; then
echo "FAIL: within_limit test should PASS, got $pass_status"
rc=1
fi
if [ "$fail_status" != "MLE" ]; then
echo "FAIL: exceeds_limit test should be MLE, got $fail_status"
rc=1
fi
if [ "${pass_peak:-0}" -le 0 ]; then
echo "FAIL: within_limit peak memory not reported (got $pass_peak)"
rc=1
fi
exit $rc
- name: Assert enforcement (Windows)
if: matrix.toolchain.system == 'Windows'
shell: powershell
working-directory: ${{ env.EXAMPLE_DIR }}
run: |
$ErrorActionPreference = 'Stop'
$report = Get-Content report.json -Raw | ConvertFrom-Json
$pass = $report.groups | Where-Object { $_.name -eq 'within_limit' } |
Select-Object -ExpandProperty tests |
Where-Object { $_.name -eq 'allocate_16mb' }
$fail = $report.groups | Where-Object { $_.name -eq 'exceeds_limit' } |
Select-Object -ExpandProperty tests |
Where-Object { $_.name -eq 'allocate_256mb' }
$passStatus = $pass.status
$failStatus = $fail.status
$passPeak = if ($pass.peak_memory_kb) { [int]$pass.peak_memory_kb } else { 0 }
$failPeak = if ($fail.peak_memory_kb) { [int]$fail.peak_memory_kb } else { 0 }
Write-Host "within_limit/allocate_16mb: status=$passStatus peak=$passPeak KiB"
Write-Host "exceeds_limit/allocate_256mb: status=$failStatus peak=$failPeak KiB"
$rc = 0
if ($passStatus -ne 'PASS') {
Write-Host "FAIL: within_limit test should PASS, got $passStatus"
$rc = 1
}
if ($failStatus -ne 'MLE') {
Write-Host "FAIL: exceeds_limit test should be MLE, got $failStatus"
$rc = 1
}
if ($passPeak -le 0) {
Write-Host "FAIL: within_limit peak memory not reported (got $passPeak)"
$rc = 1
}
exit $rc

View File

@@ -18,20 +18,31 @@ jobs:
with:
go-version: '1.22'
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cross-compile
shell: bash
run: |
tag="${GITHUB_REF#refs/tags/}"
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o "dist/judge-linux-amd64" ./cmd/cli
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o "dist/judge-windows-amd64.exe" ./cmd/cli
ls -la dist/
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o "dist/judge-darwin-arm64" ./cmd/cli
CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o "dist/judge-darwin-amd64"
- name: Build VS Code extension
shell: bash
run: |
npm install -g @vscode/vsce
cd editor/vscode-jdg
vsce package --allow-missing-repository -o "../../dist/jdg-language.vsix"
- name: Create release
uses: https://gitea.com/actions/gitea-release-action@main
with:
files: |-
dist/judge-linux-amd64
dist/judge-windows-amd64.exe
dist/*
api_key: ${{ secrets.RELEASE_TOKEN }}
title: ${{ github.ref_name }}

5
.gitignore vendored
View File

@@ -1,2 +1,5 @@
example/c-sum/solution
example/solution/solution
example/solution/solution
.DS_Store
*/.DS_Store
.claude

View File

@@ -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,27 +71,17 @@ 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)
}
r := runner.New(f, runner.Config{
WorkDir: solutionDir,
BinaryName: binary,
Wrapper: wrapper,
WorkDir: solutionDir,
BinaryName: binary,
Wrapper: wrapper,
TargetBuild: targetBuild,
})
result := r.Run()
@@ -91,6 +98,91 @@ func main() {
}
}
func parseSuite(path string) *dsl.File {
f, warns, err := dsl.ParseFile(path)
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
}
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 +220,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 +247,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)

View File

@@ -1,28 +1,38 @@
package dsl
import "time"
import (
"time"
)
type File struct {
Build string
BuildLinux string
BuildWindows string
BuildDarwin string
Timeout time.Duration
Binary string // executable name produced by build (default: solution)
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
}
type Group struct {
Name string
Weight float64
Timeout time.Duration
Env map[string]string
Scoring ScoringMode
Wrapper string // exec wrapper command (e.g., "valgrind --error-exitcode=1")
Name string
Weight float64
Timeout time.Duration
MemoryLimit int64
Env map[string]string
Scoring ScoringMode
Wrapper string
Tests []*Test
Pattern *Pattern
@@ -31,8 +41,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 {
@@ -42,6 +52,8 @@ type Pattern struct {
DirsGlob string
InputFile string
OutputFile string
Args []string
}
func (p *Pattern) IsDirMode() bool {
@@ -49,10 +61,11 @@ func (p *Pattern) IsDirMode() bool {
}
type Test struct {
Name string
Timeout time.Duration
Env map[string]string
Wrapper string
Name string
Timeout time.Duration
MemoryLimit int64
Env map[string]string
Wrapper string
Stdin *string
Args []string
@@ -64,41 +77,20 @@ type Test struct {
OutFiles map[string]string
}
type Matcher interface {
matcherNode()
func (t *Test) SetInputFile(inputName string, inputContent []byte) {
t.InFiles[inputName] = string(inputContent)
}
type ExactMatcher struct {
Value string
func (t *Test) SetStdin(inputContent []byte) {
s := string(inputContent)
t.Stdin = &s
}
func (ExactMatcher) matcherNode() {}
type ContainsMatcher struct {
Substr string
func (t *Test) SetOutputFile(outputName string, outputContent []byte) {
t.OutFiles[outputName] = string(outputContent)
t.Stdout = NoMatcher{}
}
func (ContainsMatcher) matcherNode() {}
type RegexMatcher struct {
Pattern string
func (t *Test) SetStdout(outputContent []byte) {
t.Stdout = ExactMatcher{Value: string(outputContent)}
}
func (RegexMatcher) matcherNode() {}
type NumericEpsMatcher struct {
Epsilon float64
Value string
}
func (NumericEpsMatcher) matcherNode() {}
type AnyOrderMatcher struct {
Lines []string
}
func (AnyOrderMatcher) matcherNode() {}
type NoMatcher struct{}
func (NoMatcher) matcherNode() {}

75
dsl/ast_test.go Normal file
View File

@@ -0,0 +1,75 @@
package dsl
import "testing"
func newTest() *Test {
return &Test{
InFiles: map[string]string{},
OutFiles: map[string]string{},
}
}
func TestSetInputFile(t *testing.T) {
tst := newTest()
tst.SetInputFile("input.txt", []byte("hello"))
if got := tst.InFiles["input.txt"]; got != "hello" {
t.Errorf("InFiles[input.txt] = %q, want %q", got, "hello")
}
if tst.Stdin != nil {
t.Errorf("Stdin should remain nil, got %v", *tst.Stdin)
}
}
func TestSetStdin(t *testing.T) {
tst := newTest()
tst.SetStdin([]byte("data\n"))
if tst.Stdin == nil {
t.Fatal("Stdin is nil")
}
if *tst.Stdin != "data\n" {
t.Errorf("Stdin = %q, want %q", *tst.Stdin, "data\n")
}
if len(tst.InFiles) != 0 {
t.Errorf("InFiles should be empty, got %v", tst.InFiles)
}
}
func TestSetOutputFileSetsNoMatcher(t *testing.T) {
tst := newTest()
tst.SetOutputFile("out.txt", []byte("result"))
if got := tst.OutFiles["out.txt"]; got != "result" {
t.Errorf("OutFiles[out.txt] = %q, want %q", got, "result")
}
if _, ok := tst.Stdout.(NoMatcher); !ok {
t.Errorf("Stdout = %T, want NoMatcher", tst.Stdout)
}
}
func TestSetStdoutSetsExactMatcher(t *testing.T) {
tst := newTest()
tst.SetStdout([]byte("expected\n"))
m, ok := tst.Stdout.(ExactMatcher)
if !ok {
t.Fatalf("Stdout = %T, want ExactMatcher", tst.Stdout)
}
if m.Value != "expected\n" {
t.Errorf("ExactMatcher.Value = %q, want %q", m.Value, "expected\n")
}
if len(tst.OutFiles) != 0 {
t.Errorf("OutFiles should be empty, got %v", tst.OutFiles)
}
}
func TestIsDirModeTrue(t *testing.T) {
p := &Pattern{DirsGlob: "tests/*"}
if !p.IsDirMode() {
t.Error("IsDirMode() = false, want true")
}
}
func TestIsDirModeFalse(t *testing.T) {
p := &Pattern{InputGlob: "tests/*.in"}
if p.IsDirMode() {
t.Error("IsDirMode() = true, want false")
}
}

152
dsl/build.go Normal file
View File

@@ -0,0 +1,152 @@
package dsl
import (
"maps"
"slices"
)
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"
}
}
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"
}
}
type BuildConfig struct {
Name string
Language string
Standard string
Sources []string
Includes []string
Output string
Profile BuildProfile
Warnings WarningLevel
Sanitize []string
Wrapper string
Defines map[string]string
Link []string
Extra []string
Platforms []string
Compilers []string
Linux *BuildConfig
Windows *BuildConfig
Darwin *BuildConfig
}
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{}
}
maps.Copy(dst.Defines, src.Defines)
}
}
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
}
func (b *BuildConfig) AppliesTo(os, compiler string) bool {
if len(b.Platforms) > 0 && !slices.Contains(b.Platforms, os) {
return false
}
if len(b.Compilers) > 0 && !slices.Contains(b.Compilers, compiler) {
return false
}
return true
}
type ToolchainSpec struct {
Name string
Platforms []string
Binary string
Class string
}

355
dsl/build_parser.go Normal file
View 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
}

379
dsl/build_parser_test.go Normal file
View File

@@ -0,0 +1,379 @@
package dsl
import (
"strings"
"testing"
)
const buildDefaultsPrefix = `
build_defaults {
language = "c"
standard = "c11"
sources = "*.c"
output = "sol"
warnings = strict
}
`
func TestBuildAllFieldsParsed(t *testing.T) {
src := `
build_defaults {
language = "cpp"
standard = "c++17"
sources = "a.cpp" "b.cpp"
includes = "inc" "inc2"
output = "sol"
warnings = pedantic
wrapper = "timeout"
sanitize = "address" "undefined"
link = "-lm"
extra = "-g"
platforms = "linux" "darwin"
compilers = "gcc" "clang"
define("DEBUG") = "1"
define("VERSION") = "2"
}
build "release" {
profile = release
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
bd := f.BuildDefaults
if bd == nil {
t.Fatal("no build_defaults")
}
if bd.Language != "cpp" || bd.Standard != "c++17" || bd.Output != "sol" {
t.Errorf("basic fields wrong: %+v", bd)
}
if bd.Warnings != WarningsPedantic {
t.Errorf("Warnings = %v", bd.Warnings)
}
if bd.Wrapper != "timeout" {
t.Errorf("Wrapper = %q", bd.Wrapper)
}
if len(bd.Sources) != 2 || len(bd.Includes) != 2 || len(bd.Sanitize) != 2 {
t.Errorf("lists: sources=%v includes=%v sanitize=%v", bd.Sources, bd.Includes, bd.Sanitize)
}
if len(bd.Link) != 1 || len(bd.Extra) != 1 {
t.Errorf("link/extra: %v / %v", bd.Link, bd.Extra)
}
if len(bd.Platforms) != 2 || len(bd.Compilers) != 2 {
t.Errorf("platforms/compilers: %v / %v", bd.Platforms, bd.Compilers)
}
if bd.Defines["DEBUG"] != "1" || bd.Defines["VERSION"] != "2" {
t.Errorf("Defines = %v", bd.Defines)
}
}
func TestBuildOSOverrides(t *testing.T) {
src := `
build_defaults {
language = "c"
standard = "c11"
sources = "main.c"
output = "sol"
warnings = strict
}
build "release" {
profile = release
linux { extra = "-pthread" }
windows { extra = "/MT" }
darwin { extra = "-framework CoreFoundation" }
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
b := f.Builds[0]
if b.Linux == nil || b.Windows == nil || b.Darwin == nil {
t.Errorf("OS overrides missing: %+v", b)
}
}
func TestBuildOSNestedForbidden(t *testing.T) {
src := buildDefaultsPrefix + `
build "r" {
profile = release
linux {
darwin { extra = "-x" }
}
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "nested inside another OS override") {
t.Errorf("want nested OS override error, got %v", err)
}
}
func TestBuildDuplicateOSOverride(t *testing.T) {
src := buildDefaultsPrefix + `
build "r" {
profile = release
linux { extra = "-a" }
linux { extra = "-b" }
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "duplicate linux override") {
t.Errorf("want duplicate linux override, got %v", err)
}
}
func TestBuildUnknownField(t *testing.T) {
src := `
build_defaults {
bogus = "x"
language = "c"
standard = "c11"
sources = "main.c"
output = "sol"
warnings = strict
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Errorf("want unknown field error, got %v", err)
}
}
func TestBuildUnknownProfile(t *testing.T) {
src := `
build_defaults {
language = "c"
standard = "c11"
sources = "main.c"
output = "sol"
warnings = strict
}
build "weird" { profile = bogus }
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "unknown profile") {
t.Errorf("want unknown profile error, got %v", err)
}
}
func TestBuildUnknownWarnings(t *testing.T) {
src := `
build_defaults {
language = "c"
standard = "c11"
sources = "main.c"
output = "sol"
warnings = bogus
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "unknown warnings level") {
t.Errorf("want unknown warnings error, got %v", err)
}
}
func TestBuildWarningsDefaultAndStrict(t *testing.T) {
src := `
build_defaults {
language = "c"
standard = "c11"
sources = "main.c"
output = "sol"
warnings = default
}
build "r" {
warnings = strict
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
if f.BuildDefaults.Warnings != WarningsDefault {
t.Errorf("defaults warnings = %v", f.BuildDefaults.Warnings)
}
if f.Builds[0].Warnings != WarningsStrict {
t.Errorf("build warnings = %v", f.Builds[0].Warnings)
}
}
func TestBuildInvalidPlatform(t *testing.T) {
src := buildDefaultsPrefix + `
build "r" {
profile = release
platforms = "bsd"
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "unknown platform") {
t.Errorf("want unknown platform error, got %v", err)
}
}
func TestBuildAssignStringMissingAssign(t *testing.T) {
src := `
build_defaults {
language "c"
standard = "c11"
sources = "main.c"
output = "sol"
warnings = strict
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "expected") {
t.Errorf("want expected = error, got %v", err)
}
}
func TestBuildAssignStringListMissingAssign(t *testing.T) {
src := `
build_defaults {
language = "c"
standard = "c11"
sources "main.c"
output = "sol"
warnings = strict
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "expected") {
t.Errorf("want expected = error, got %v", err)
}
}
func TestBuildDefineErrors(t *testing.T) {
cases := []string{
`build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict define "K" = "v" }`,
`build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict define(K) = "v" }`,
`build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict define("K" = "v" }`,
`build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict define("K") "v" }`,
`build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict define("K") = K }`,
}
for i, src := range cases {
full := src + "\ngroup(\"g\") { weight = 1.0 test(\"t\") { stdout = \"\" } }"
if _, _, err := Parse(full); err == nil {
t.Errorf("case %d: expected error", i)
}
}
}
func TestBuildNonIdentInBlock(t *testing.T) {
src := `
build_defaults { "not-an-ident" = "x" }
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "unexpected token") {
t.Errorf("want unexpected token error, got %v", err)
}
}
func TestToolchainsDuplicateInSameBlock(t *testing.T) {
src := `
toolchains {
gcc { platforms = "linux" }
gcc { platforms = "darwin" }
}
build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict }
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "duplicate toolchain") {
t.Errorf("want duplicate toolchain error, got %v", err)
}
}
func TestToolchainsMissingPlatforms(t *testing.T) {
src := `
toolchains {
gcc { binary = "gcc-13" }
}
build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict }
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "platforms is required") {
t.Errorf("want platforms-required error, got %v", err)
}
}
func TestToolchainsBadName(t *testing.T) {
src := `
toolchains {
42 { platforms = "linux" }
}
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "expected toolchain name") {
t.Errorf("want toolchain name error, got %v", err)
}
}
func TestToolchainsUnknownField(t *testing.T) {
src := `
toolchains {
gcc { platforms = "linux" bogus = "x" }
}
build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict }
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "unknown field") {
t.Errorf("want unknown field error, got %v", err)
}
}
func TestToolchainsUnknownClass(t *testing.T) {
src := `
toolchains {
gcc { platforms = "linux" class = bogus }
}
build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict }
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "unknown compiler class") {
t.Errorf("want unknown class error, got %v", err)
}
}
func TestToolchainsClassAndBinary(t *testing.T) {
src := `
toolchains {
gcc13 { platforms = "linux" binary = "gcc-13" class = gnu }
msvc { platforms = "windows" class = msvc }
}
build_defaults { language="c" standard="c11" sources="x.c" output="s" warnings=strict }
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(f.Toolchains) != 2 {
t.Fatalf("toolchains = %d", len(f.Toolchains))
}
if f.Toolchains[0].Binary != "gcc-13" || f.Toolchains[0].Class != "gnu" {
t.Errorf("gcc13 = %+v", f.Toolchains[0])
}
if f.Toolchains[1].Class != "msvc" {
t.Errorf("msvc class = %q", f.Toolchains[1].Class)
}
}

74
dsl/build_string_test.go Normal file
View File

@@ -0,0 +1,74 @@
package dsl
import "testing"
func TestBuildProfileString(t *testing.T) {
cases := []struct {
p BuildProfile
want string
}{
{ProfileRelease, "release"},
{ProfileDebug, "debug"},
{ProfileSanitized, "sanitized"},
{ProfileUnset, "unset"},
{BuildProfile(999), "unset"},
}
for _, c := range cases {
if got := c.p.String(); got != c.want {
t.Errorf("BuildProfile(%d).String() = %q, want %q", c.p, got, c.want)
}
}
}
func TestWarningLevelString(t *testing.T) {
cases := []struct {
w WarningLevel
want string
}{
{WarningsDefault, "default"},
{WarningsStrict, "strict"},
{WarningsPedantic, "pedantic"},
{WarningsUnset, "unset"},
{WarningLevel(999), "unset"},
}
for _, c := range cases {
if got := c.w.String(); got != c.want {
t.Errorf("WarningLevel(%d).String() = %q, want %q", c.w, got, c.want)
}
}
}
func TestMergeFromWrapperAndDefines(t *testing.T) {
dst := &BuildConfig{}
src := &BuildConfig{
Wrapper: "valgrind",
Defines: map[string]string{"DEBUG": "1", "VERSION": "2"},
}
dst.MergeFrom(src)
if dst.Wrapper != "valgrind" {
t.Errorf("Wrapper = %q, want valgrind", dst.Wrapper)
}
if dst.Defines["DEBUG"] != "1" || dst.Defines["VERSION"] != "2" {
t.Errorf("Defines = %v", dst.Defines)
}
}
func TestMergeFromDefinesIntoExisting(t *testing.T) {
dst := &BuildConfig{Defines: map[string]string{"KEEP": "old"}}
src := &BuildConfig{Defines: map[string]string{"NEW": "1", "KEEP": "new"}}
dst.MergeFrom(src)
if dst.Defines["KEEP"] != "new" {
t.Errorf("KEEP = %q, want new (overridden)", dst.Defines["KEEP"])
}
if dst.Defines["NEW"] != "1" {
t.Errorf("NEW = %q, want 1", dst.Defines["NEW"])
}
}
func TestMergeFromNilSrc(t *testing.T) {
dst := &BuildConfig{Language: "c"}
dst.MergeFrom(nil)
if dst.Language != "c" {
t.Errorf("dst mutated on nil merge: %+v", dst)
}
}

455
dsl/build_test.go Normal file
View 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)
}
})
}
}

210
dsl/include_test.go Normal file
View File

@@ -0,0 +1,210 @@
package dsl
import (
"os"
"path/filepath"
"strings"
"testing"
)
func writeTempJdg(t *testing.T, dir, name, content string) 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)
}
return path
}
func TestIncludeBasicMerge(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
toolchains {
gcc { platforms = "linux" }
}
build_defaults {
language = "c"
standard = "c11"
sources = "*.c"
output = "solution"
warnings = strict
}
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
include "common.jdg"
build "release" { profile = release }
group("g") {
weight = 1.0
test("t") { stdout = "ok\n" }
}
`)
f, _, err := ParseFile(mainPath)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(f.Toolchains) != 1 || f.Toolchains[0].Name != "gcc" {
t.Errorf("toolchains not merged: %+v", f.Toolchains)
}
if f.BuildDefaults == nil || f.BuildDefaults.Language != "c" {
t.Errorf("build_defaults not merged: %+v", f.BuildDefaults)
}
if len(f.Builds) != 1 || f.Builds[0].Name != "release" {
t.Errorf("local build lost: %+v", f.Builds)
}
}
func TestIncludeLocalOverridesIncluded(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
build_defaults {
language = "c"
standard = "c11"
warnings = strict
}
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
include "common.jdg"
build_defaults {
standard = "c17"
}
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
f, _, err := ParseFile(mainPath)
if err != nil {
t.Fatalf("parse: %v", err)
}
if f.BuildDefaults.Standard != "c17" {
t.Errorf("local should override included standard: got %q", f.BuildDefaults.Standard)
}
if f.BuildDefaults.Language != "c" {
t.Errorf("language should survive from include: got %q", f.BuildDefaults.Language)
}
if f.BuildDefaults.Warnings != WarningsStrict {
t.Errorf("warnings should survive from include: got %v", f.BuildDefaults.Warnings)
}
}
func TestIncludeRelativePathResolution(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "shared/tools.jdg", `
toolchains {
gcc { platforms = "linux" }
}
`)
mainPath := writeTempJdg(t, dir, "suite/main.jdg", `
include "../shared/tools.jdg"
build "release" {
language = "c"
sources = "solution.c"
output = "solution"
profile = release
}
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
f, _, err := ParseFile(mainPath)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(f.Toolchains) != 1 {
t.Errorf("relative include failed to resolve: %+v", f.Toolchains)
}
}
func TestIncludeDuplicateToolchainErrors(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
toolchains {
gcc { platforms = "linux" }
}
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
include "common.jdg"
toolchains {
gcc { platforms = "linux" }
}
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
_, _, err := ParseFile(mainPath)
if err == nil {
t.Fatal("expected duplicate toolchain error")
}
if !strings.Contains(err.Error(), "duplicate toolchain") {
t.Errorf("error %q does not mention duplicate toolchain", err.Error())
}
}
func TestIncludeCircularDetection(t *testing.T) {
dir := t.TempDir()
aPath := writeTempJdg(t, dir, "a.jdg", `
include "b.jdg"
group("ga") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
writeTempJdg(t, dir, "b.jdg", `
include "a.jdg"
`)
_, _, err := ParseFile(aPath)
if err == nil {
t.Fatal("expected circular include error")
}
if !strings.Contains(err.Error(), "circular include") {
t.Errorf("error %q does not mention circular include", err.Error())
}
}
func TestIncludeMissingFileErrors(t *testing.T) {
dir := t.TempDir()
mainPath := writeTempJdg(t, dir, "main.jdg", `
include "nonexistent.jdg"
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
_, _, err := ParseFile(mainPath)
if err == nil {
t.Fatal("expected missing include error")
}
if !strings.Contains(err.Error(), "nonexistent.jdg") {
t.Errorf("error %q does not reference the missing path", err.Error())
}
}
func TestParseRejectsIncludeWithoutFileContext(t *testing.T) {
src := `
include "common.jdg"
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`
_, _, err := Parse(src)
if err == nil {
t.Fatal("expected error: include without file context")
}
if !strings.Contains(err.Error(), "file context") {
t.Errorf("error %q does not mention file context", err.Error())
}
}

View File

@@ -14,6 +14,7 @@ const (
TOKEN_FLOAT
TOKEN_INT
TOKEN_DURATION
TOKEN_SIZE
TOKEN_LBRACE
TOKEN_RBRACE
@@ -37,6 +38,8 @@ func (t TokenType) String() string {
return "INT"
case TOKEN_DURATION:
return "DURATION"
case TOKEN_SIZE:
return "SIZE"
case TOKEN_LBRACE:
return "{"
case TOKEN_RBRACE:
@@ -353,6 +356,10 @@ func (l *Lexer) readNumberOrDuration(line, col int) (Token, error) {
}
}
if sizeSuffix := l.tryReadSizeSuffix(); sizeSuffix != "" {
return Token{TOKEN_SIZE, buf.String() + sizeSuffix, line, col}, nil
}
suffix := l.tryReadDurationSuffix()
if suffix != "" {
return Token{TOKEN_DURATION, buf.String() + suffix, line, col}, nil
@@ -364,6 +371,35 @@ func (l *Lexer) readNumberOrDuration(line, col int) (Token, error) {
return Token{TOKEN_INT, buf.String(), line, col}, nil
}
func (l *Lexer) tryReadSizeSuffix() string {
ch, ok := l.peek()
if !ok {
return ""
}
var unit rune
switch ch {
case 'B':
l.advance()
return "B"
case 'K', 'M', 'G':
unit = ch
default:
return ""
}
l.advance()
var buf strings.Builder
buf.WriteRune(unit)
if next, ok := l.peek(); ok && next == 'i' {
l.advance()
buf.WriteRune('i')
}
if next, ok := l.peek(); ok && next == 'B' {
l.advance()
buf.WriteRune('B')
}
return buf.String()
}
func (l *Lexer) tryReadDurationSuffix() string {
ch, ok := l.peek()
if !ok {

175
dsl/lexer_test.go Normal file
View File

@@ -0,0 +1,175 @@
package dsl
import (
"strings"
"testing"
)
func TestTokenStringAndUnknownType(t *testing.T) {
tok := Token{Type: TOKEN_IDENT, Value: "foo", Line: 2, Col: 5}
s := tok.String()
if !strings.Contains(s, "IDENT") || !strings.Contains(s, "foo") {
t.Errorf("Token.String() = %q", s)
}
if got := TokenType(999).String(); got != "UNKNOWN" {
t.Errorf("TokenType(999).String() = %q, want UNKNOWN", got)
}
for tt, want := range map[TokenType]string{
TOKEN_STRING: "STRING",
TOKEN_FLOAT: "FLOAT",
TOKEN_INT: "INT",
TOKEN_DURATION: "DURATION",
TOKEN_SIZE: "SIZE",
TOKEN_LBRACE: "{",
TOKEN_RBRACE: "}",
TOKEN_LPAREN: "(",
TOKEN_RPAREN: ")",
TOKEN_ASSIGN: "=",
TOKEN_TILDE: "~",
TOKEN_EOF: "EOF",
} {
if got := tt.String(); got != want {
t.Errorf("TokenType(%d) = %q, want %q", tt, got, want)
}
}
}
func TestLexerLineComment(t *testing.T) {
src := `
// leading comment
build "make" // trailing
group("g") { // inside
weight = 1.0
test("t") { stdout = "" }
}
`
if _, _, err := Parse(src); err != nil {
t.Errorf("parse with comments: %v", err)
}
}
func TestLexerUnterminatedString(t *testing.T) {
_, _, err := Parse(`build "unterminated`)
if err == nil || !strings.Contains(err.Error(), "unterminated") {
t.Errorf("want unterminated string error, got %v", err)
}
}
func TestLexerUnknownEscape(t *testing.T) {
_, _, err := Parse(`build "bad\zescape"`)
if err == nil || !strings.Contains(err.Error(), "unknown escape") {
t.Errorf("want unknown escape error, got %v", err)
}
}
func TestLexerEscapeSequences(t *testing.T) {
src := `build "a\nb\tc\\d\"e"`
f, _, err := Parse(src + "\ngroup(\"g\") { weight = 1.0 test(\"t\") { stdout = \"\" } }")
if err != nil {
t.Fatalf("parse: %v", err)
}
if f.Build != "a\nb\tc\\d\"e" {
t.Errorf("escape sequences = %q", f.Build)
}
}
func TestLexerUnexpectedCharacter(t *testing.T) {
_, _, err := Parse("build @invalid")
if err == nil || !strings.Contains(err.Error(), "unexpected character") {
t.Errorf("want unexpected character error, got %v", err)
}
}
func TestLexerUnterminatedHeredoc(t *testing.T) {
src := "build \"x\"\ngroup(\"g\") { weight = 1.0 test(\"t\") { stdin = \"\"\"\nnever closed\n } }"
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "unterminated heredoc") {
t.Errorf("want unterminated heredoc, got %v", err)
}
}
func TestLexerSizeSuffixes(t *testing.T) {
cases := map[string]int64{
"1024": 1024,
"1B": 1,
"1K": 1024,
"1KB": 1024,
"1KiB": 1024,
"1M": 1024 * 1024,
"1MB": 1024 * 1024,
"1MiB": 1024 * 1024,
"1G": 1024 * 1024 * 1024,
"1GB": 1024 * 1024 * 1024,
"1GiB": 1024 * 1024 * 1024,
}
for literal, want := range cases {
src := "build \"x\"\nmemory_limit = " + literal + "\ngroup(\"g\") { weight = 1.0 test(\"t\") { stdout = \"\" } }"
f, _, err := Parse(src)
if err != nil {
t.Errorf("parse %q: %v", literal, err)
continue
}
if f.MemoryLimit != want {
t.Errorf("memory_limit %s = %d, want %d", literal, f.MemoryLimit, want)
}
}
}
func TestLexerDurationSuffixes(t *testing.T) {
cases := []string{"5ms", "10s", "2m"}
for _, d := range cases {
src := "build \"x\"\ntimeout " + d + "\ngroup(\"g\") { weight = 1.0 test(\"t\") { stdout = \"\" } }"
if _, _, err := Parse(src); err != nil {
t.Errorf("parse timeout %s: %v", d, err)
}
}
}
func TestLexerNegativeInt(t *testing.T) {
src := `
build "x"
group("g") {
weight = 1.0
test("t") {
exitCode = -1
stdout = ""
}
}
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
tst := f.Groups[0].Tests[0]
if tst.ExitCode == nil || *tst.ExitCode != -1 {
t.Errorf("ExitCode = %v, want -1", tst.ExitCode)
}
}
func TestLexerFloatNumber(t *testing.T) {
src := `
build "x"
group("g") {
weight = 0.75
test("t") { stdout = "" }
}
group("g2") {
weight = 0.25
test("t") { stdout = "" }
}
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
if f.Groups[0].Weight != 0.75 {
t.Errorf("weight = %v", f.Groups[0].Weight)
}
}
func TestLexerInvalidSizeUnit(t *testing.T) {
_, _, err := Parse("build \"x\"\nmemory_limit = 10Z\n")
if err == nil {
t.Error("expected error for invalid size unit")
}
}

View File

@@ -1,4 +1,4 @@
package runner
package dsl
import (
"fmt"
@@ -7,57 +7,90 @@ import (
"sort"
"strconv"
"strings"
"github.com/Mond1c/judge/dsl"
)
func applyMatcher(label string, m dsl.Matcher, actual string) []string {
switch m := m.(type) {
case dsl.NoMatcher:
return nil
case dsl.ExactMatcher:
if actual != m.Value {
return []string{fmt.Sprintf(
"%s mismatch:\n expected: %q\n actual: %q",
label, m.Value, actual,
)}
}
return nil
case dsl.ContainsMatcher:
if !strings.Contains(actual, m.Substr) {
return []string{fmt.Sprintf(
"%s: expected to contain %q, got %q",
label, m.Substr, actual,
)}
}
return nil
case dsl.RegexMatcher:
re, err := regexp.Compile(m.Pattern)
if err != nil {
return []string{fmt.Sprintf("%s: invalid regex %q: %v", label, m.Pattern, err)}
}
if !re.MatchString(actual) {
return []string{fmt.Sprintf(
"%s: %q does not match regex %q",
label, actual, m.Pattern,
)}
}
return nil
// TODO: maybe move to ast.go
type Matcher interface {
matcherNode()
case dsl.NumericEpsMatcher:
errs := matchNumericEps(label, m, actual)
return errs
case dsl.AnyOrderMatcher:
return matchAnyOrder(label, m, actual)
default:
return []string{fmt.Sprintf("unknown matcher type %T", m)}
}
Match(label, actual string) []string
}
func matchNumericEps(label string, m dsl.NumericEpsMatcher, actual string) []string {
type ExactMatcher struct {
Value string
}
func (ExactMatcher) matcherNode() {}
// TODO: think about pointer receivers
func (m ExactMatcher) Match(label, actual string) []string {
if actual != m.Value {
return []string{fmt.Sprintf(
"%s mismatch:\n expected: %q\n actual: %q",
label, m.Value, actual,
)}
}
return nil
}
type ContainsMatcher struct {
Substr string
}
func (ContainsMatcher) matcherNode() {}
func (m ContainsMatcher) Match(label, actual string) []string {
if !strings.Contains(actual, m.Substr) {
return []string{fmt.Sprintf(
"%s: expected to contain %q, got %q",
label, m.Substr, actual,
)}
}
return nil
}
type RegexMatcher struct {
Pattern string
}
func (RegexMatcher) matcherNode() {}
func (m RegexMatcher) Match(label, actual string) []string {
re, err := regexp.Compile(m.Pattern)
if err != nil {
return []string{fmt.Sprintf("%s: invalid regex %q: %v", label, m.Pattern, err)}
}
if !re.MatchString(actual) {
return []string{fmt.Sprintf(
"%s: %q does not match regex %q",
label, actual, m.Pattern,
)}
}
return nil
}
type NumericEpsMatcher struct {
Epsilon float64
Value string
}
func (NumericEpsMatcher) matcherNode() {}
func parseNumbers(s string) ([]float64, error) {
fields := strings.Fields(s)
nums := make([]float64, 0, len(fields))
for _, f := range fields {
n, err := strconv.ParseFloat(f, 64)
if err != nil {
return nil, fmt.Errorf("not a number: %q", f)
}
nums = append(nums, n)
}
return nums, nil
}
func (m NumericEpsMatcher) Match(label, actual string) []string {
expectedNums, err := parseNumbers(m.Value)
if err != nil {
return []string{fmt.Sprintf("%s: cannot parse expected numbers %q: %v", label, m.Value, err)}
@@ -85,20 +118,21 @@ func matchNumericEps(label string, m dsl.NumericEpsMatcher, actual string) []str
return errs
}
func parseNumbers(s string) ([]float64, error) {
fields := strings.Fields(s)
nums := make([]float64, 0, len(fields))
for _, f := range fields {
n, err := strconv.ParseFloat(f, 64)
if err != nil {
return nil, fmt.Errorf("not a number: %q", f)
}
nums = append(nums, n)
}
return nums, nil
type AnyOrderMatcher struct {
Lines []string
}
func matchAnyOrder(label string, m dsl.AnyOrderMatcher, actual string) []string {
func (AnyOrderMatcher) matcherNode() {}
func splitLines(s string) []string {
s = strings.TrimRight(s, "\n")
if s == "" {
return []string{}
}
return strings.Split(s, "\n")
}
func (m AnyOrderMatcher) Match(label, actual string) []string {
actualLines := splitLines(actual)
expectedLines := make([]string, len(m.Lines))
copy(expectedLines, m.Lines)
@@ -125,10 +159,10 @@ func matchAnyOrder(label string, m dsl.AnyOrderMatcher, actual string) []string
return errs
}
func splitLines(s string) []string {
s = strings.TrimRight(s, "\n")
if s == "" {
return []string{}
}
return strings.Split(s, "\n")
type NoMatcher struct{}
func (NoMatcher) matcherNode() {}
func (NoMatcher) Match(label, actual string) []string {
return nil
}

187
dsl/matcher_test.go Normal file
View File

@@ -0,0 +1,187 @@
package dsl
import (
"strings"
"testing"
)
func TestExactMatcherPass(t *testing.T) {
m := ExactMatcher{Value: "hello\n"}
if errs := m.Match("stdout", "hello\n"); errs != nil {
t.Errorf("expected pass, got %v", errs)
}
}
func TestExactMatcherMismatch(t *testing.T) {
m := ExactMatcher{Value: "hello\n"}
errs := m.Match("stdout", "world\n")
if len(errs) != 1 {
t.Fatalf("want 1 error, got %d: %v", len(errs), errs)
}
if !strings.Contains(errs[0], "stdout mismatch") {
t.Errorf("error missing label: %q", errs[0])
}
if !strings.Contains(errs[0], "hello") || !strings.Contains(errs[0], "world") {
t.Errorf("error missing values: %q", errs[0])
}
}
func TestContainsMatcherPass(t *testing.T) {
m := ContainsMatcher{Substr: "needle"}
if errs := m.Match("stdout", "haystack with a needle inside"); errs != nil {
t.Errorf("expected pass, got %v", errs)
}
}
func TestContainsMatcherMissing(t *testing.T) {
m := ContainsMatcher{Substr: "needle"}
errs := m.Match("stdout", "only hay here")
if len(errs) != 1 {
t.Fatalf("want 1 error, got %d: %v", len(errs), errs)
}
if !strings.Contains(errs[0], "needle") {
t.Errorf("error missing substring: %q", errs[0])
}
}
func TestRegexMatcherPass(t *testing.T) {
m := RegexMatcher{Pattern: `^hello .*!$`}
if errs := m.Match("stdout", "hello world!"); errs != nil {
t.Errorf("expected pass, got %v", errs)
}
}
func TestRegexMatcherMismatch(t *testing.T) {
m := RegexMatcher{Pattern: `^\d+$`}
errs := m.Match("stdout", "not-a-number")
if len(errs) != 1 {
t.Fatalf("want 1 error, got %d: %v", len(errs), errs)
}
if !strings.Contains(errs[0], "does not match") {
t.Errorf("error unexpected: %q", errs[0])
}
}
func TestRegexMatcherInvalidPattern(t *testing.T) {
m := RegexMatcher{Pattern: `[`}
errs := m.Match("stdout", "anything")
if len(errs) != 1 {
t.Fatalf("want 1 error, got %d: %v", len(errs), errs)
}
if !strings.Contains(errs[0], "invalid regex") {
t.Errorf("error unexpected: %q", errs[0])
}
}
func TestNumericEpsMatcherPassWithinEps(t *testing.T) {
m := NumericEpsMatcher{Epsilon: 0.01, Value: "1.0 2.0 3.0"}
if errs := m.Match("stdout", "1.005 1.999 3.0"); errs != nil {
t.Errorf("expected pass, got %v", errs)
}
}
func TestNumericEpsMatcherExceedsEps(t *testing.T) {
m := NumericEpsMatcher{Epsilon: 0.01, Value: "1.0 2.0"}
errs := m.Match("stdout", "1.0 2.5")
if len(errs) != 1 {
t.Fatalf("want 1 error, got %d: %v", len(errs), errs)
}
if !strings.Contains(errs[0], "number[1]") {
t.Errorf("error missing index: %q", errs[0])
}
}
func TestNumericEpsMatcherCountMismatch(t *testing.T) {
m := NumericEpsMatcher{Epsilon: 0.01, Value: "1 2 3"}
errs := m.Match("stdout", "1 2")
if len(errs) != 1 {
t.Fatalf("want 1 error, got %d: %v", len(errs), errs)
}
if !strings.Contains(errs[0], "expected 3 numbers, got 2") {
t.Errorf("error unexpected: %q", errs[0])
}
}
func TestNumericEpsMatcherBadExpected(t *testing.T) {
m := NumericEpsMatcher{Epsilon: 0.01, Value: "1 foo"}
errs := m.Match("stdout", "1 2")
if len(errs) != 1 || !strings.Contains(errs[0], "cannot parse expected") {
t.Errorf("want expected-parse error, got %v", errs)
}
}
func TestNumericEpsMatcherBadActual(t *testing.T) {
m := NumericEpsMatcher{Epsilon: 0.01, Value: "1 2"}
errs := m.Match("stdout", "1 bar")
if len(errs) != 1 || !strings.Contains(errs[0], "cannot parse actual") {
t.Errorf("want actual-parse error, got %v", errs)
}
}
func TestNumericEpsMatcherMultipleErrors(t *testing.T) {
m := NumericEpsMatcher{Epsilon: 0.01, Value: "1 2 3"}
errs := m.Match("stdout", "9 8 7")
if len(errs) != 3 {
t.Errorf("want 3 errors, got %d: %v", len(errs), errs)
}
}
func TestAnyOrderMatcherPassReordered(t *testing.T) {
m := AnyOrderMatcher{Lines: []string{"a", "b", "c"}}
if errs := m.Match("stdout", "c\nb\na\n"); errs != nil {
t.Errorf("expected pass, got %v", errs)
}
}
func TestAnyOrderMatcherPassNoTrailingNewline(t *testing.T) {
m := AnyOrderMatcher{Lines: []string{"x", "y"}}
if errs := m.Match("stdout", "y\nx"); errs != nil {
t.Errorf("expected pass, got %v", errs)
}
}
func TestAnyOrderMatcherCountMismatch(t *testing.T) {
m := AnyOrderMatcher{Lines: []string{"a", "b"}}
errs := m.Match("stdout", "a\nb\nc\n")
if len(errs) != 1 || !strings.Contains(errs[0], "expected 2 lines, got 3") {
t.Errorf("want count error, got %v", errs)
}
}
func TestAnyOrderMatcherLineMismatch(t *testing.T) {
m := AnyOrderMatcher{Lines: []string{"a", "b"}}
errs := m.Match("stdout", "a\nz\n")
if len(errs) != 1 || !strings.Contains(errs[0], "line mismatch") {
t.Errorf("want line mismatch, got %v", errs)
}
}
func TestAnyOrderMatcherEmptyBoth(t *testing.T) {
m := AnyOrderMatcher{Lines: []string{}}
if errs := m.Match("stdout", ""); errs != nil {
t.Errorf("expected pass on empty, got %v", errs)
}
}
func TestNoMatcherAlwaysPasses(t *testing.T) {
m := NoMatcher{}
if errs := m.Match("stdout", "anything at all\n"); errs != nil {
t.Errorf("NoMatcher should never fail, got %v", errs)
}
if errs := m.Match("stderr", ""); errs != nil {
t.Errorf("NoMatcher should never fail on empty, got %v", errs)
}
}
func TestSplitLinesEmpty(t *testing.T) {
if got := splitLines(""); len(got) != 0 {
t.Errorf("splitLines(\"\") = %v, want empty", got)
}
}
func TestSplitLinesTrailingNewline(t *testing.T) {
got := splitLines("a\nb\n")
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
t.Errorf("splitLines trailing = %v, want [a b]", got)
}
}

95
dsl/memory_test.go Normal file
View File

@@ -0,0 +1,95 @@
package dsl
import "testing"
func TestParseSizeLiteral(t *testing.T) {
cases := []struct {
in string
want int64
}{
{"256", 256},
{"256B", 256},
{"1K", 1024},
{"2KB", 2 * 1024},
{"4KiB", 4 * 1024},
{"256M", 256 * 1024 * 1024},
{"256MB", 256 * 1024 * 1024},
{"512MiB", 512 * 1024 * 1024},
{"1G", 1024 * 1024 * 1024},
{"2GB", 2 * 1024 * 1024 * 1024},
{"3GiB", 3 * 1024 * 1024 * 1024},
}
for _, c := range cases {
got, err := parseSizeLiteral(c.in, 0, 0)
if err != nil {
t.Errorf("parseSizeLiteral(%q) error: %v", c.in, err)
continue
}
if got != c.want {
t.Errorf("parseSizeLiteral(%q) = %d, want %d", c.in, got, c.want)
}
}
}
func TestParseSizeLiteralInvalid(t *testing.T) {
bad := []string{"abc", "100TB", "10XB", ""}
for _, s := range bad {
if _, err := parseSizeLiteral(s, 0, 0); err == nil {
t.Errorf("parseSizeLiteral(%q) expected error", s)
}
}
}
func TestParseMemoryLimit(t *testing.T) {
src := `
build "go build -o solution ."
timeout 10s
memory_limit = 256MB
group("g1") {
weight = 0.5
memory_limit = 128MiB
test("t1") {
stdout = "ok\n"
}
test("t2") {
memory_limit = 64M
stdout = "ok\n"
}
}
group("g2") {
weight = 0.5
test("inherits") {
stdout = "ok\n"
}
}
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if f.MemoryLimit != 256*1024*1024 {
t.Errorf("file memory: got %d", f.MemoryLimit)
}
g1 := f.Groups[0]
if g1.MemoryLimit != 128*1024*1024 {
t.Errorf("g1 memory: got %d", g1.MemoryLimit)
}
if g1.Tests[0].MemoryLimit != 128*1024*1024 {
t.Errorf("g1.t1 memory (inherited from group): got %d", g1.Tests[0].MemoryLimit)
}
if g1.Tests[1].MemoryLimit != 64*1024*1024 {
t.Errorf("g1.t2 memory (override): got %d", g1.Tests[1].MemoryLimit)
}
g2 := f.Groups[1]
if g2.MemoryLimit != 256*1024*1024 {
t.Errorf("g2 memory (inherited from file): got %d", g2.MemoryLimit)
}
if g2.Tests[0].MemoryLimit != 256*1024*1024 {
t.Errorf("g2.inherits memory: got %d", g2.Tests[0].MemoryLimit)
}
}

81
dsl/merge.go Normal file
View File

@@ -0,0 +1,81 @@
package dsl
import "fmt"
func mergeFiles(dst, src *File) error {
if src.Build != "" {
dst.Build = src.Build
}
if src.BuildLinux != "" {
dst.BuildLinux = src.BuildLinux
}
if src.BuildWindows != "" {
dst.BuildWindows = src.BuildWindows
}
if src.BuildDarwin != "" {
dst.BuildDarwin = src.BuildDarwin
}
if src.BuildDefaults != nil {
if dst.BuildDefaults == nil {
dst.BuildDefaults = &BuildConfig{}
}
dst.BuildDefaults.MergeFrom(src.BuildDefaults)
}
seenTC := map[string]bool{}
for _, t := range dst.Toolchains {
seenTC[t.Name] = true
}
for _, t := range src.Toolchains {
if seenTC[t.Name] {
return fmt.Errorf("duplicate toolchain %q", t.Name)
}
seenTC[t.Name] = true
dst.Toolchains = append(dst.Toolchains, t)
}
seenB := map[string]bool{}
for _, b := range dst.Builds {
seenB[b.Name] = true
}
for _, b := range src.Builds {
if seenB[b.Name] {
return fmt.Errorf("duplicate build %q", b.Name)
}
seenB[b.Name] = true
dst.Builds = append(dst.Builds, b)
}
seenG := map[string]bool{}
for _, g := range dst.Groups {
seenG[g.Name] = true
}
for _, g := range src.Groups {
if seenG[g.Name] {
return fmt.Errorf("duplicate group %q", g.Name)
}
seenG[g.Name] = true
dst.Groups = append(dst.Groups, g)
}
if src.Timeout != 0 {
dst.Timeout = src.Timeout
}
if src.MemoryLimit != 0 {
dst.MemoryLimit = src.MemoryLimit
}
if src.Binary != "" {
dst.Binary = src.Binary
}
if src.Sources != "" {
dst.Sources = src.Sources
}
if src.NormalizeCRLF {
dst.NormalizeCRLF = true
}
if src.TrimTrailingWS {
dst.TrimTrailingWS = true
}
return nil
}

220
dsl/merge_test.go Normal file
View File

@@ -0,0 +1,220 @@
package dsl
import (
"strings"
"testing"
"time"
)
func TestMergeLegacyBuildFields(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
build "make"
build_linux "make linux"
build_windows "make windows"
build_darwin "make darwin"
timeout 7s
memory_limit = 256MB
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
include "common.jdg"
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
f, _, err := ParseFile(mainPath)
if err != nil {
t.Fatalf("parse: %v", err)
}
if f.Build != "make" || f.BuildLinux != "make linux" ||
f.BuildWindows != "make windows" || f.BuildDarwin != "make darwin" {
t.Errorf("legacy build fields not merged: %+v", f)
}
if f.Timeout != 7*time.Second {
t.Errorf("Timeout = %v, want 7s", f.Timeout)
}
if f.MemoryLimit != 256*1024*1024 {
t.Errorf("MemoryLimit = %d", f.MemoryLimit)
}
}
func TestMergeDuplicateToolchainFromInclude(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
toolchains {
gcc { platforms = "linux" }
}
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
toolchains {
gcc { platforms = "linux" }
}
include "common.jdg"
build "make"
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
_, _, err := ParseFile(mainPath)
if err == nil {
t.Fatal("expected duplicate toolchain error from merge")
}
if !strings.Contains(err.Error(), "duplicate toolchain") {
t.Errorf("error %q does not mention duplicate toolchain", err.Error())
}
}
func TestMergeDuplicateBuildErrors(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
build "release" { profile = release }
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
build_defaults {
language = "c"
standard = "c11"
sources = "*.c"
output = "solution"
warnings = strict
}
build "release" { profile = debug }
include "common.jdg"
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
_, _, err := ParseFile(mainPath)
if err == nil {
t.Fatal("expected duplicate build error")
}
if !strings.Contains(err.Error(), "duplicate build") {
t.Errorf("error %q does not mention duplicate build", err.Error())
}
}
func TestMergeDuplicateGroupErrors(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
group("shared") { weight = 0.5 test("t") { stdout = "ok\n" } }
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
build "make"
group("shared") { weight = 0.5 test("t2") { stdout = "ok\n" } }
include "common.jdg"
`)
_, _, err := ParseFile(mainPath)
if err == nil {
t.Fatal("expected duplicate group error")
}
if !strings.Contains(err.Error(), "duplicate group") {
t.Errorf("error %q does not mention duplicate group", err.Error())
}
}
func TestMergeGroupsAppended(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
build "make"
group("included") { weight = 0.5 test("t") { stdout = "ok\n" } }
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
include "common.jdg"
group("local") { weight = 0.5 test("t") { stdout = "ok\n" } }
`)
f, _, err := ParseFile(mainPath)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(f.Groups) != 2 {
t.Fatalf("want 2 groups, got %d", len(f.Groups))
}
names := []string{f.Groups[0].Name, f.Groups[1].Name}
if !(contains(names, "included") && contains(names, "local")) {
t.Errorf("groups = %v", names)
}
}
func TestMergeBuildsAppended(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
build_defaults {
language = "c"
standard = "c11"
sources = "*.c"
output = "solution"
warnings = strict
}
build "release" { profile = release }
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
include "common.jdg"
build "debug" { profile = debug }
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
f, _, err := ParseFile(mainPath)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(f.Builds) != 2 {
t.Fatalf("want 2 builds, got %d", len(f.Builds))
}
}
func TestMergeBinarySourcesAndFlags(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
build "make"
binary = "solution"
sources = "*.c"
normalize_crlf = true
trim_trailing_ws = true
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
include "common.jdg"
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
f, _, err := ParseFile(mainPath)
if err != nil {
t.Fatalf("parse: %v", err)
}
if f.Binary != "solution" {
t.Errorf("Binary = %q, want solution", f.Binary)
}
if f.Sources != "*.c" {
t.Errorf("Sources = %q, want *.c", f.Sources)
}
if !f.NormalizeCRLF {
t.Error("NormalizeCRLF not merged")
}
if !f.TrimTrailingWS {
t.Error("TrimTrailingWS not merged")
}
}
func TestMergeLocalOverridesTimeout(t *testing.T) {
dir := t.TempDir()
writeTempJdg(t, dir, "common.jdg", `
build "make"
timeout 10s
memory_limit = 128MB
`)
mainPath := writeTempJdg(t, dir, "main.jdg", `
include "common.jdg"
timeout 3s
memory_limit = 64MB
group("g") { weight = 1.0 test("t") { stdout = "ok\n" } }
`)
f, _, err := ParseFile(mainPath)
if err != nil {
t.Fatalf("parse: %v", err)
}
if f.Timeout != 3*time.Second {
t.Errorf("Timeout = %v, want local 3s", f.Timeout)
}
if f.MemoryLimit != 64*1024*1024 {
t.Errorf("MemoryLimit = %d, want local 64MB", f.MemoryLimit)
}
}
func contains(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}

View File

@@ -3,14 +3,18 @@ package dsl
import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"time"
)
type Parser struct {
tokens []Token
pos int
warns []string
tokens []Token
pos int
warns []string
includeBaseDir string
visited map[string]bool
}
func NewParser(tokens []Token) *Parser {
@@ -72,9 +76,67 @@ func Parse(src string) (*File, []string, error) {
if err != nil {
return nil, parser.Warnings(), err
}
if err := parser.finalize(file); err != nil {
return nil, parser.Warnings(), err
}
return file, parser.Warnings(), nil
}
func ParseFile(path string) (*File, []string, error) {
abs, err := filepath.Abs(path)
if err != nil {
return nil, nil, fmt.Errorf("resolve %q: %w", path, err)
}
visited := map[string]bool{}
parser, file, err := parseFileOnDisk(abs, visited)
if err != nil {
var warns []string
if parser != nil {
warns = parser.Warnings()
}
return nil, warns, err
}
if err := parser.finalize(file); err != nil {
return nil, parser.Warnings(), err
}
return file, parser.Warnings(), nil
}
func parseFileOnDisk(absPath string, visited map[string]bool) (*Parser, *File, error) {
if visited[absPath] {
return nil, nil, fmt.Errorf("circular include: %s", absPath)
}
visited[absPath] = true
src, err := os.ReadFile(absPath)
if err != nil {
return nil, nil, fmt.Errorf("read %s: %w", absPath, err)
}
tokens, err := NewLexer(string(src)).Tokenize()
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", absPath, err)
}
p := NewParser(tokens)
p.includeBaseDir = filepath.Dir(absPath)
p.visited = visited
file, err := p.parseFile()
if err != nil {
return p, nil, fmt.Errorf("%s: %w", absPath, err)
}
return p, file, nil
}
func (p *Parser) finalize(f *File) error {
if err := p.validateWeights(f); err != nil {
return err
}
if err := validateBuilds(f); err != nil {
return err
}
return nil
}
func (p *Parser) parseFile() (*File, error) {
f := &File{}
@@ -91,7 +153,72 @@ func (p *Parser) parseFile() (*File, error) {
if err != nil {
return nil, err
}
f.Build = s.Value
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
}
if f.BuildDefaults == nil {
f.BuildDefaults = &BuildConfig{}
}
f.BuildDefaults.MergeFrom(bc)
case "toolchains":
p.advance()
specs, err := p.parseToolchainsBlock()
if err != nil {
return nil, err
}
existing := map[string]bool{}
for _, tc := range f.Toolchains {
existing[tc.Name] = true
}
for _, tc := range specs {
if existing[tc.Name] {
return nil, fmt.Errorf("duplicate toolchain %q", tc.Name)
}
existing[tc.Name] = true
f.Toolchains = append(f.Toolchains, tc)
}
case "include":
p.advance()
s, err := p.expect(TOKEN_STRING)
if err != nil {
return nil, err
}
if p.includeBaseDir == "" {
return nil, fmt.Errorf("%d:%d: `include` requires file context (use ParseFile, not Parse)", s.Line, s.Col)
}
target := s.Value
if !filepath.IsAbs(target) {
target = filepath.Join(p.includeBaseDir, target)
}
abs, err := filepath.Abs(target)
if err != nil {
return nil, fmt.Errorf("%d:%d: resolve include %q: %w", s.Line, s.Col, s.Value, err)
}
childParser, child, err := parseFileOnDisk(abs, p.visited)
if err != nil {
return nil, fmt.Errorf("%d:%d: include %q: %w", s.Line, s.Col, s.Value, err)
}
for _, w := range childParser.Warnings() {
p.warn(w)
}
if err := mergeFiles(f, child); err != nil {
return nil, fmt.Errorf("%d:%d: include %q: %w", s.Line, s.Col, s.Value, err)
}
case "build_linux":
p.advance()
@@ -128,6 +255,17 @@ func (p *Parser) parseFile() (*File, error) {
}
f.Binary = s.Value
case "sources":
p.advance()
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
return nil, err
}
s, err := p.expect(TOKEN_STRING)
if err != nil {
return nil, err
}
f.Sources = s.Value
case "normalize_crlf":
p.advance()
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
@@ -158,8 +296,19 @@ func (p *Parser) parseFile() (*File, error) {
}
f.Timeout = d
case "memory_limit":
p.advance()
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
return nil, err
}
n, err := p.parseSize()
if err != nil {
return nil, err
}
f.MemoryLimit = n
case "group":
g, err := p.parseGroup(f.Timeout)
g, err := p.parseGroup(f.Timeout, f.MemoryLimit)
if err != nil {
return nil, err
}
@@ -170,11 +319,28 @@ func (p *Parser) parseFile() (*File, error) {
}
}
if err := p.validateWeights(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")
}
return f, nil
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 {
@@ -191,7 +357,7 @@ func (p *Parser) validateWeights(f *File) error {
return nil
}
func (p *Parser) parseGroup(defaultTimeout time.Duration) (*Group, error) {
func (p *Parser) parseGroup(defaultTimeout time.Duration, defaultMemory int64) (*Group, error) {
if err := p.expectIdent("group"); err != nil {
return nil, err
}
@@ -210,10 +376,11 @@ func (p *Parser) parseGroup(defaultTimeout time.Duration) (*Group, error) {
}
g := &Group{
Name: name.Value,
Timeout: defaultTimeout,
Env: map[string]string{},
Scoring: ScoringPartial,
Name: name.Value,
Timeout: defaultTimeout,
MemoryLimit: defaultMemory,
Env: map[string]string{},
Scoring: ScoringPartial,
}
for !p.isRBrace() {
@@ -245,6 +412,17 @@ func (p *Parser) parseGroup(defaultTimeout time.Duration) (*Group, error) {
}
g.Timeout = d
case "memory_limit":
p.advance()
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
return nil, err
}
n, err := p.parseSize()
if err != nil {
return nil, err
}
g.MemoryLimit = n
case "scoring":
p.advance()
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
@@ -296,7 +474,7 @@ func (p *Parser) parseGroup(defaultTimeout time.Duration) (*Group, error) {
g.Wrapper = s.Value
case "test":
test, err := p.parseTest(g.Timeout)
test, err := p.parseTest(g.Timeout, g.MemoryLimit)
if err != nil {
return nil, err
}
@@ -320,7 +498,7 @@ func (p *Parser) parseGroup(defaultTimeout time.Duration) (*Group, error) {
return g, nil
}
func (p *Parser) parseTest(defaultTimeout time.Duration) (*Test, error) {
func (p *Parser) parseTest(defaultTimeout time.Duration, defaultMemory int64) (*Test, error) {
if err := p.expectIdent("test"); err != nil {
return nil, err
}
@@ -340,14 +518,15 @@ func (p *Parser) parseTest(defaultTimeout time.Duration) (*Test, error) {
zero := 0
test := &Test{
Name: name.Value,
Timeout: defaultTimeout,
Env: map[string]string{},
InFiles: map[string]string{},
OutFiles: map[string]string{},
ExitCode: &zero,
Stdout: NoMatcher{},
Stderr: NoMatcher{},
Name: name.Value,
Timeout: defaultTimeout,
MemoryLimit: defaultMemory,
Env: map[string]string{},
InFiles: map[string]string{},
OutFiles: map[string]string{},
ExitCode: &zero,
Stdout: NoMatcher{},
Stderr: NoMatcher{},
}
for !p.isRBrace() {
@@ -417,6 +596,17 @@ func (p *Parser) parseTest(defaultTimeout time.Duration) (*Test, error) {
}
test.Timeout = d
case "memory_limit":
p.advance()
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
return nil, err
}
n, err := p.parseSize()
if err != nil {
return nil, err
}
test.MemoryLimit = n
case "wrapper":
p.advance()
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
@@ -589,25 +779,40 @@ func (p *Parser) parsePattern() (*Pattern, error) {
if _, err := p.expect(TOKEN_ASSIGN); err != nil {
return nil, err
}
val, err := p.expect(TOKEN_STRING)
if err != nil {
return nil, err
}
switch t.Value {
case "input":
val, err := p.expect(TOKEN_STRING)
if err != nil {
return nil, err
}
if pat.DirsGlob != "" {
pat.InputFile = val.Value
} else {
pat.InputGlob = val.Value
}
case "output":
val, err := p.expect(TOKEN_STRING)
if err != nil {
return nil, err
}
if pat.DirsGlob != "" {
pat.OutputFile = val.Value
} else {
pat.OutputGlob = val.Value
}
case "dirs":
val, err := p.expect(TOKEN_STRING)
if err != nil {
return nil, err
}
pat.DirsGlob = val.Value
case "args":
xs, err := p.parseStringList()
if err != nil {
return nil, err
}
pat.Args = xs
default:
return nil, fmt.Errorf("%d:%d: unknown pattern field %q", t.Line, t.Col, t.Value)
}
@@ -669,6 +874,57 @@ func (p *Parser) parseInt() (int, error) {
return n, nil
}
func (p *Parser) parseSize() (int64, error) {
t := p.peek()
switch t.Type {
case TOKEN_SIZE:
p.advance()
return parseSizeLiteral(t.Value, t.Line, t.Col)
case TOKEN_INT:
p.advance()
n, err := strconv.ParseInt(t.Value, 10, 64)
if err != nil {
return 0, fmt.Errorf("%d:%d: invalid size %q", t.Line, t.Col, t.Value)
}
return n, nil
default:
return 0, fmt.Errorf("%d:%d: expected size (e.g. 256MB, 1GiB), got %s %q", t.Line, t.Col, t.Type, t.Value)
}
}
func parseSizeLiteral(s string, line, col int) (int64, error) {
i := 0
for i < len(s) && (s[i] >= '0' && s[i] <= '9') {
i++
}
if i == 0 {
return 0, fmt.Errorf("%d:%d: invalid size %q", line, col, s)
}
numPart := s[:i]
unit := s[i:]
n, err := strconv.ParseInt(numPart, 10, 64)
if err != nil {
return 0, fmt.Errorf("%d:%d: invalid size %q", line, col, s)
}
var mult int64
switch unit {
case "", "B":
mult = 1
case "K", "KB", "KiB":
mult = 1024
case "M", "MB", "MiB":
mult = 1024 * 1024
case "G", "GB", "GiB":
mult = 1024 * 1024 * 1024
default:
return 0, fmt.Errorf("%d:%d: unknown size unit %q (use B/K/M/G or KiB/MiB/GiB)", line, col, unit)
}
if n < 0 {
return 0, fmt.Errorf("%d:%d: size must be non-negative", line, col)
}
return n * mult, nil
}
func (p *Parser) parseDuration() (time.Duration, error) {
t := p.peek()
if t.Type != TOKEN_DURATION {

299
dsl/parser_errors_test.go Normal file
View File

@@ -0,0 +1,299 @@
package dsl
import (
"strings"
"testing"
)
func TestParserErrorCases(t *testing.T) {
const prefix = `build "go build ."` + "\n"
cases := []struct {
name string
body string
want string
}{
{
name: "group missing lparen",
body: `group "g" { weight = 1.0 test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group name not string",
body: `group(foo) { weight = 1.0 test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group missing rparen",
body: `group("g" { weight = 1.0 test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group missing lbrace",
body: `group("g") weight = 1.0`,
want: "expected",
},
{
name: "group non-ident in body",
body: `group("g") { 42 }`,
want: "unexpected token",
},
{
name: "group weight missing assign",
body: `group("g") { weight 1.0 test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group weight non-number",
body: `group("g") { weight = "bad" test("t") { stdout = "" } }`,
want: "",
},
{
name: "group timeout missing assign",
body: `group("g") { weight = 1.0 timeout 5s test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group timeout non-duration",
body: `group("g") { weight = 1.0 timeout = "5s" test("t") { stdout = "" } }`,
want: "",
},
{
name: "group memory_limit missing assign",
body: `group("g") { weight = 1.0 memory_limit 64MB test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group memory_limit non-size",
body: `group("g") { weight = 1.0 memory_limit = "64MB" test("t") { stdout = "" } }`,
want: "",
},
{
name: "group scoring missing assign",
body: `group("g") { weight = 1.0 scoring partial test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group scoring non-ident",
body: `group("g") { weight = 1.0 scoring = "partial" test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group env missing lparen",
body: `group("g") { weight = 1.0 env "K" = "v" test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group env key not string",
body: `group("g") { weight = 1.0 env(K) = "v" test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group env missing rparen",
body: `group("g") { weight = 1.0 env("K" = "v" test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group env missing assign",
body: `group("g") { weight = 1.0 env("K") "v" test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group env value not string",
body: `group("g") { weight = 1.0 env("K") = K test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group wrapper missing assign",
body: `group("g") { weight = 1.0 wrapper "x" test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group wrapper not string",
body: `group("g") { weight = 1.0 wrapper = X test("t") { stdout = "" } }`,
want: "expected",
},
{
name: "group unclosed",
body: `group("g") { weight = 1.0 test("t") { stdout = "" }`,
want: "",
},
{
name: "group pattern error",
body: `group("g") { weight = 1.0 pattern { bogus = 1 } }`,
want: "",
},
{
name: "test missing lparen",
body: `group("g") { weight = 1.0 test "t" { stdout = "" } }`,
want: "expected",
},
{
name: "test name not string",
body: `group("g") { weight = 1.0 test(t) { stdout = "" } }`,
want: "expected",
},
{
name: "test missing rparen",
body: `group("g") { weight = 1.0 test("t" { stdout = "" } }`,
want: "expected",
},
{
name: "test missing lbrace",
body: `group("g") { weight = 1.0 test("t") stdout = "" }`,
want: "expected",
},
{
name: "test non-ident in body",
body: `group("g") { weight = 1.0 test("t") { 42 } }`,
want: "unexpected token",
},
{
name: "test stdin missing assign",
body: `group("g") { weight = 1.0 test("t") { stdin "x" stdout = "" } }`,
want: "expected",
},
{
name: "test stdin not string",
body: `group("g") { weight = 1.0 test("t") { stdin = 1 stdout = "" } }`,
want: "expected",
},
{
name: "test args missing assign",
body: `group("g") { weight = 1.0 test("t") { args "x" stdout = "" } }`,
want: "expected",
},
{
name: "test exitCode missing assign",
body: `group("g") { weight = 1.0 test("t") { exitCode 0 stdout = "" } }`,
want: "expected",
},
{
name: "test exitCode not int",
body: `group("g") { weight = 1.0 test("t") { exitCode = "x" stdout = "" } }`,
want: "",
},
{
name: "test timeout missing assign",
body: `group("g") { weight = 1.0 test("t") { timeout 2s stdout = "" } }`,
want: "expected",
},
{
name: "test timeout not duration",
body: `group("g") { weight = 1.0 test("t") { timeout = "2s" stdout = "" } }`,
want: "",
},
{
name: "test memory_limit missing assign",
body: `group("g") { weight = 1.0 test("t") { memory_limit 64MB stdout = "" } }`,
want: "expected",
},
{
name: "test memory_limit not size",
body: `group("g") { weight = 1.0 test("t") { memory_limit = "64MB" stdout = "" } }`,
want: "",
},
{
name: "test wrapper missing assign",
body: `group("g") { weight = 1.0 test("t") { wrapper "x" stdout = "" } }`,
want: "expected",
},
{
name: "test wrapper not string",
body: `group("g") { weight = 1.0 test("t") { wrapper = X stdout = "" } }`,
want: "expected",
},
{
name: "test env missing lparen",
body: `group("g") { weight = 1.0 test("t") { env "K" = "v" stdout = "" } }`,
want: "expected",
},
{
name: "test env key not string",
body: `group("g") { weight = 1.0 test("t") { env(K) = "v" stdout = "" } }`,
want: "expected",
},
{
name: "test env missing rparen",
body: `group("g") { weight = 1.0 test("t") { env("K" = "v" stdout = "" } }`,
want: "expected",
},
{
name: "test env missing assign",
body: `group("g") { weight = 1.0 test("t") { env("K") "v" stdout = "" } }`,
want: "expected",
},
{
name: "test env value not string",
body: `group("g") { weight = 1.0 test("t") { env("K") = K stdout = "" } }`,
want: "expected",
},
{
name: "test file missing lparen",
body: `group("g") { weight = 1.0 test("t") { file "x" = "y" stdout = "" } }`,
want: "expected",
},
{
name: "test file key not string",
body: `group("g") { weight = 1.0 test("t") { file(x) = "y" stdout = "" } }`,
want: "expected",
},
{
name: "test file missing rparen",
body: `group("g") { weight = 1.0 test("t") { file("x" = "y" stdout = "" } }`,
want: "expected",
},
{
name: "test file missing assign",
body: `group("g") { weight = 1.0 test("t") { file("x") "y" stdout = "" } }`,
want: "expected",
},
{
name: "test file value not string",
body: `group("g") { weight = 1.0 test("t") { file("x") = y stdout = "" } }`,
want: "expected",
},
{
name: "test outFile missing lparen",
body: `group("g") { weight = 1.0 test("t") { outFile "x" = "y" stdout = "" } }`,
want: "expected",
},
{
name: "test outFile key not string",
body: `group("g") { weight = 1.0 test("t") { outFile(x) = "y" stdout = "" } }`,
want: "expected",
},
{
name: "test outFile missing rparen",
body: `group("g") { weight = 1.0 test("t") { outFile("x" = "y" stdout = "" } }`,
want: "expected",
},
{
name: "test outFile missing assign",
body: `group("g") { weight = 1.0 test("t") { outFile("x") "y" stdout = "" } }`,
want: "expected",
},
{
name: "test outFile value not string",
body: `group("g") { weight = 1.0 test("t") { outFile("x") = y stdout = "" } }`,
want: "expected",
},
{
name: "test unclosed",
body: `group("g") { weight = 1.0 test("t") { stdout = ""`,
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, _, err := Parse(prefix + c.body)
if err == nil {
t.Fatalf("expected error, got nil")
}
if c.want != "" && !strings.Contains(err.Error(), c.want) {
t.Errorf("error = %q, want substring %q", err.Error(), c.want)
}
})
}
}

237
dsl/parser_features_test.go Normal file
View File

@@ -0,0 +1,237 @@
package dsl
import (
"strings"
"testing"
"time"
)
func parseOrFatal(t *testing.T, src string) *File {
t.Helper()
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse error: %v", err)
}
return f
}
func parseExpectError(t *testing.T, src, wantSubstr string) {
t.Helper()
_, _, err := Parse(src)
if err == nil {
t.Fatalf("expected error containing %q, got nil", wantSubstr)
}
if !strings.Contains(err.Error(), wantSubstr) {
t.Errorf("error = %q, want substring %q", err.Error(), wantSubstr)
}
}
func TestParseTestFileAndOutFile(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
test("files") {
file("input.txt") = "1 2 3\n"
outFile("result.txt") = "6\n"
stdout = ""
}
}
`
f := parseOrFatal(t, src)
tst := f.Groups[0].Tests[0]
if tst.InFiles["input.txt"] != "1 2 3\n" {
t.Errorf("InFiles[input.txt] = %q", tst.InFiles["input.txt"])
}
if tst.OutFiles["result.txt"] != "6\n" {
t.Errorf("OutFiles[result.txt] = %q", tst.OutFiles["result.txt"])
}
}
func TestParseTestEnvAndWrapper(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
test("env") {
env("FOO") = "bar"
env("BAZ") = "qux"
wrapper = "valgrind"
stdout = ""
}
}
`
f := parseOrFatal(t, src)
tst := f.Groups[0].Tests[0]
if tst.Env["FOO"] != "bar" || tst.Env["BAZ"] != "qux" {
t.Errorf("Env = %v", tst.Env)
}
if tst.Wrapper != "valgrind" {
t.Errorf("Wrapper = %q", tst.Wrapper)
}
}
func TestParseTestTimeoutAndMemoryOverride(t *testing.T) {
src := `
build "go build ."
timeout 10s
group("g") {
weight = 1.0
memory_limit = 256MB
test("override") {
timeout = 2s
memory_limit = 64MB
stdout = ""
}
}
`
f := parseOrFatal(t, src)
tst := f.Groups[0].Tests[0]
if tst.Timeout != 2*time.Second {
t.Errorf("Timeout = %v, want 2s", tst.Timeout)
}
if tst.MemoryLimit != 64*1024*1024 {
t.Errorf("MemoryLimit = %d, want %d", tst.MemoryLimit, 64*1024*1024)
}
}
func TestParseTestExitCodeNonZero(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
test("fail") {
exitCode = 42
stdout = ""
}
}
`
f := parseOrFatal(t, src)
tst := f.Groups[0].Tests[0]
if tst.ExitCode == nil || *tst.ExitCode != 42 {
t.Errorf("ExitCode = %v, want 42", tst.ExitCode)
}
}
func TestParseTestUnknownKeyword(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
test("bad") {
bogus = "x"
}
}
`
parseExpectError(t, src, `"bogus"`)
}
func TestParseGroupScoringPartial(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
scoring = partial
test("t") { stdout = "" }
}
`
f := parseOrFatal(t, src)
if f.Groups[0].Scoring != ScoringPartial {
t.Errorf("Scoring = %v, want partial", f.Groups[0].Scoring)
}
}
func TestParseGroupScoringAllOrNone(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
scoring = all_or_none
test("t") { stdout = "" }
}
`
f := parseOrFatal(t, src)
if f.Groups[0].Scoring != ScoringAllOrNone {
t.Errorf("Scoring = %v, want all_or_none", f.Groups[0].Scoring)
}
}
func TestParseGroupScoringUnknown(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
scoring = magic
test("t") { stdout = "" }
}
`
parseExpectError(t, src, "unknown scoring mode")
}
func TestParseGroupEnvAndWrapper(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
env("LANG") = "C"
env("DEBUG") = "1"
wrapper = "strace"
test("t") { stdout = "" }
}
`
f := parseOrFatal(t, src)
g := f.Groups[0]
if g.Env["LANG"] != "C" || g.Env["DEBUG"] != "1" {
t.Errorf("group Env = %v", g.Env)
}
if g.Wrapper != "strace" {
t.Errorf("group Wrapper = %q", g.Wrapper)
}
}
func TestParseGroupMemoryLimit(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
memory_limit = 128MB
test("t") { stdout = "" }
}
`
f := parseOrFatal(t, src)
if f.Groups[0].MemoryLimit != 128*1024*1024 {
t.Errorf("group MemoryLimit = %d", f.Groups[0].MemoryLimit)
}
}
func TestParseGroupUnknownKeyword(t *testing.T) {
src := `
build "go build ."
group("g") {
weight = 1.0
foobar = 1
test("t") { stdout = "" }
}
`
parseExpectError(t, src, `"foobar"`)
}
func TestParseGroupMissingWeight(t *testing.T) {
src := `
build "go build ."
group("g") {
test("t") { stdout = "" }
}
`
_, warns, err := Parse(src)
if err != nil && !strings.Contains(err.Error(), "weight") {
return
}
if err == nil && len(warns) == 0 {
t.Error("expected error or warning about missing weight")
}
}

193
dsl/parser_misc_test.go Normal file
View File

@@ -0,0 +1,193 @@
package dsl
import (
"strings"
"testing"
)
func TestParsePatternDirsMode(t *testing.T) {
src := `
build "make"
group("g") {
weight = 1.0
pattern {
dirs = "tests/*"
input = "in.txt"
output = "out.txt"
args = "--case" "{name}"
}
}
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
pat := f.Groups[0].Pattern
if pat == nil {
t.Fatal("no pattern")
}
if pat.DirsGlob != "tests/*" {
t.Errorf("DirsGlob = %q", pat.DirsGlob)
}
if pat.InputFile != "in.txt" || pat.OutputFile != "out.txt" {
t.Errorf("InputFile/OutputFile = %q/%q", pat.InputFile, pat.OutputFile)
}
if len(pat.Args) != 2 || pat.Args[0] != "--case" || pat.Args[1] != "{name}" {
t.Errorf("Args = %v", pat.Args)
}
}
func TestParsePatternUnknownField(t *testing.T) {
src := `
build "make"
group("g") {
weight = 1.0
pattern { bogus = "x" }
}
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "unknown pattern field") {
t.Errorf("want unknown pattern field error, got %v", err)
}
}
func TestParsePatternNonIdent(t *testing.T) {
src := `
build "make"
group("g") {
weight = 1.0
pattern { "x" = "y" }
}
`
_, _, err := Parse(src)
if err == nil {
t.Error("expected error on non-ident in pattern")
}
}
func TestParseNormalizeCRLFAndTrimWS(t *testing.T) {
src := `
build "make"
normalize_crlf = true
trim_trailing_ws = false
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
if !f.NormalizeCRLF {
t.Error("NormalizeCRLF should be true")
}
if f.TrimTrailingWS {
t.Error("TrimTrailingWS should be false")
}
}
func TestParseBinaryAndSources(t *testing.T) {
src := `
build "make"
binary = "sol"
sources = "main.c"
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
if f.Binary != "sol" {
t.Errorf("Binary = %q", f.Binary)
}
if f.Sources != "main.c" {
t.Errorf("Sources = %q", f.Sources)
}
}
func TestParseMemoryLimitBareInt(t *testing.T) {
src := `
build "make"
memory_limit = 1024
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
f, _, err := Parse(src)
if err != nil {
t.Fatalf("parse: %v", err)
}
if f.MemoryLimit != 1024 {
t.Errorf("MemoryLimit = %d, want 1024", f.MemoryLimit)
}
}
func TestParseBoolInvalidIdent(t *testing.T) {
_, _, err := Parse("build \"make\"\nnormalize_crlf = maybe\n")
if err == nil || !strings.Contains(err.Error(), "true/false") {
t.Errorf("want true/false error, got %v", err)
}
}
func TestParseBoolNonIdent(t *testing.T) {
_, _, err := Parse("build \"make\"\nnormalize_crlf = \"true\"\n")
if err == nil || !strings.Contains(err.Error(), "true/false") {
t.Errorf("want true/false error, got %v", err)
}
}
func TestParseTopLevelNonIdent(t *testing.T) {
_, _, err := Parse(`"stray"`)
if err == nil || !strings.Contains(err.Error(), "unexpected token") {
t.Errorf("want unexpected token error, got %v", err)
}
}
func TestParseMatcherOrAssignNoMatcher(t *testing.T) {
src := `
build "make"
group("g") {
weight = 1.0
test("t") { stdout 42 }
}
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "expected matcher") {
t.Errorf("want matcher error, got %v", err)
}
}
func TestParseStringListEmpty(t *testing.T) {
src := `
build "make"
group("g") {
weight = 1.0
test("t") { args = stdout = "" }
}
`
_, _, err := Parse(src)
if err == nil {
t.Error("expected error on empty string list")
}
}
func TestParseFileMissing(t *testing.T) {
_, _, err := ParseFile("/nonexistent/judge-test-does-not-exist.jdg")
if err == nil {
t.Error("expected error on missing file")
}
}
func TestValidateBuildsMixedLegacyAndStructured(t *testing.T) {
src := `
build "legacy shell"
build_defaults {
language = "c"
standard = "c11"
sources = "*.c"
output = "sol"
warnings = strict
}
group("g") { weight = 1.0 test("t") { stdout = "" } }
`
_, _, err := Parse(src)
if err == nil || !strings.Contains(err.Error(), "cannot mix legacy") {
t.Errorf("want mix-legacy error, got %v", err)
}
}

BIN
editor/.DS_Store vendored

Binary file not shown.

21
editor/vscode-jdg/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Mikhail Kornilovich
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -4,6 +4,7 @@
"description": "Syntax highlighting for judge test-suite files",
"version": "0.1.0",
"publisher": "judge",
"repository": {"type": "git", "url": "https://gitea.mkorn.me/admin/judge"},
"engines": { "vscode": "^1.60.0" },
"categories": ["Programming Languages"],
"contributes": {

View File

@@ -7,13 +7,19 @@
{ "include": "#comments" },
{ "include": "#heredoc" },
{ "include": "#string" },
{ "include": "#size" },
{ "include": "#duration" },
{ "include": "#number" },
{ "include": "#boolean" },
{ "include": "#top-keywords" },
{ "include": "#os-block" },
{ "include": "#build-fields" },
{ "include": "#block-keywords" },
{ "include": "#matcher-keywords" },
{ "include": "#profile-values" },
{ "include": "#warnings-values" },
{ "include": "#scoring-values" },
{ "include": "#class-values" },
{ "include": "#operators" },
{ "include": "#braces" },
{ "include": "#identifier" }
@@ -39,6 +45,10 @@
{ "name": "constant.character.escape.jdg", "match": "\\\\[nt\\\\\"]" }
]
},
"size": {
"name": "constant.numeric.size.jdg",
"match": "\\b\\d+(?:KiB|MiB|GiB|KB|MB|GB|B|K|M|G)\\b"
},
"duration": {
"name": "constant.numeric.duration.jdg",
"match": "\\b-?\\d+(?:\\.\\d+)?(?:ms|s|m|h)\\b"
@@ -53,20 +63,40 @@
},
"top-keywords": {
"name": "keyword.control.jdg",
"match": "\\b(?:build|build_linux|build_windows|build_darwin|binary|timeout|normalize_crlf|trim_trailing_ws|group|test|pattern|env|file|outFile)\\b"
"match": "\\b(?:include|build|build_defaults|build_linux|build_windows|build_darwin|toolchains|binary|sources|timeout|memory_limit|normalize_crlf|trim_trailing_ws|group|test|pattern|env|file|outFile|define)\\b"
},
"os-block": {
"name": "support.type.os.jdg",
"match": "\\b(?:linux|windows|darwin)\\b(?=\\s*\\{)"
},
"build-fields": {
"name": "variable.parameter.build.jdg",
"match": "\\b(?:language|standard|output|wrapper|includes|sanitize|link|extra|platforms|compilers|profile|warnings|class)\\b"
},
"block-keywords": {
"name": "variable.parameter.jdg",
"match": "\\b(?:weight|scoring|wrapper|stdin|stdout|stderr|args|exitCode|input|output|dirs)\\b"
"match": "\\b(?:weight|scoring|stdin|stdout|stderr|args|exitCode|input|dirs)\\b"
},
"matcher-keywords": {
"name": "support.function.jdg",
"match": "\\b(?:contains|matches|anyOrder|of)\\b"
},
"profile-values": {
"name": "constant.language.profile.jdg",
"match": "\\b(?:release|debug|sanitized)\\b"
},
"warnings-values": {
"name": "constant.language.warnings.jdg",
"match": "\\b(?:default|strict|pedantic)\\b"
},
"scoring-values": {
"name": "constant.language.jdg",
"name": "constant.language.scoring.jdg",
"match": "\\b(?:partial|all_or_none)\\b"
},
"class-values": {
"name": "constant.language.class.jdg",
"match": "\\b(?:gnu|msvc)\\b"
},
"operators": {
"name": "keyword.operator.jdg",
"match": "[=~]"

17
editor/zed/.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
grammars/*.wasm
tree-sitter-jdg/tree-sitter-jdg.wasm
tree-sitter-jdg/node_modules/
tree-sitter-jdg/bindings/
tree-sitter-jdg/binding.gyp
tree-sitter-jdg/Cargo.toml
tree-sitter-jdg/Cargo.lock
tree-sitter-jdg/Makefile
tree-sitter-jdg/Package.swift
tree-sitter-jdg/pyproject.toml
tree-sitter-jdg/setup.py
tree-sitter-jdg/go.mod
tree-sitter-jdg/go.sum
tree-sitter-jdg/bindings.gyp
tree-sitter-jdg/prebuilds/
tree-sitter-jdg/target/

23
editor/zed/extension.toml Normal file
View File

@@ -0,0 +1,23 @@
id = "jdg"
name = "JDG"
version = "0.1.0"
schema_version = 1
description = "Syntax highlighting for the judge DSL (.jdg)"
repository = "https://github.com/Mond1c/judge"
authors = ["judge contributors"]
themes = []
icon_themes = []
languages = ["languages/jdg"]
capabilities = []
[lib]
[grammars.jdg]
repository = "https://github.com/Mond1c/judge"
rev = "main"
[language_servers]
[context_servers]
[slash_commands]

View File

@@ -0,0 +1,2 @@
("{" @open "}" @close)
("(" @open ")" @close)

View File

@@ -0,0 +1,12 @@
name = "JDG"
grammar = "jdg"
path_suffixes = ["jdg"]
line_comments = ["// "]
autoclose_before = ";:.,=}])>"
brackets = [
{ start = "{", end = "}", close = true, newline = true },
{ start = "(", end = ")", close = true, newline = false },
{ start = "\"", end = "\"", close = true, newline = false, not_in = ["string", "comment"] },
]
word_characters = ["_"]
tab_size = 4

View File

@@ -0,0 +1,100 @@
(comment) @comment
[
"include"
"build"
"build_defaults"
"build_linux"
"build_windows"
"build_darwin"
"toolchains"
"binary"
"sources"
"timeout"
"memory_limit"
"group"
"test"
"pattern"
] @keyword
[
"normalize_crlf"
"trim_trailing_ws"
] @keyword
(build_scalar_field name: _ @property)
(build_list_field name: _ @property)
(bool_decl name: _ @property)
(legacy_build_platform keyword: _ @keyword)
[
"profile"
"warnings"
"define"
"platforms"
"binary"
"class"
"weight"
"scoring"
"wrapper"
"env"
"stdin"
"stdout"
"stderr"
"args"
"exitCode"
"file"
"outFile"
"input"
"output"
"dirs"
] @property
[
"linux"
"windows"
"darwin"
] @type.builtin
(profile_value) @constant.builtin
(warnings_value) @constant.builtin
(scoring_value) @constant.builtin
(class_value) @constant.builtin
(bool) @constant.builtin
"of" @keyword.operator
[
"contains"
"matches"
"anyOrder"
] @function.builtin
[
"="
"~"
] @operator
[
"{"
"}"
"("
")"
] @punctuation.bracket
(simple_string) @string
(heredoc_string) @string
(regex_string) @string.regex
(structured_build name: (string) @string.special)
(legacy_build command: (string) @string.special)
(toolchain_entry name: (string) @type)
(toolchain_entry name: (identifier) @type)
(group name: (string) @string.special)
(test name: (string) @string.special)
(include path: (string) @string.special.path)
(define_field key: (string) @constant.macro)
(env_decl key: (string) @constant.macro)
(file_decl key: (string) @string.special.path)
(out_file_decl key: (string) @string.special.path)

View File

@@ -0,0 +1,11 @@
[
(build_block)
(toolchains)
(toolchain_entry)
(group)
(test)
(pattern)
(any_order_matcher)
] @indent
"}" @end

View File

@@ -0,0 +1,7 @@
((comment) @injection.content
(#set! injection.language "comment"))
((regex_matcher
pattern: (regex_string) @injection.content)
(#set! injection.language "regex")
(#set! injection.include-children))

View File

@@ -0,0 +1,23 @@
(structured_build
"build" @context
name: (string) @name) @item
(build_defaults
"build_defaults" @context) @item
(toolchains
"toolchains" @context) @item
(toolchain_entry
name: (_) @name) @item
(group
"group" @context
name: (string) @name) @item
(test
"test" @context
name: (string) @name) @item
(pattern
"pattern" @context) @item

View File

@@ -0,0 +1,39 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{json,toml,yml,gyp}]
indent_style = space
indent_size = 2
[*.js]
indent_style = space
indent_size = 2
[*.rs]
indent_style = space
indent_size = 4
[*.{c,cc,h}]
indent_style = space
indent_size = 4
[*.{py,pyi}]
indent_style = space
indent_size = 4
[*.swift]
indent_style = space
indent_size = 4
[*.go]
indent_style = tab
indent_size = 8
[Makefile]
indent_style = tab
indent_size = 8

View File

@@ -0,0 +1,11 @@
* text eol=lf
src/*.json linguist-generated
src/parser.c linguist-generated
src/tree_sitter/* linguist-generated
bindings/** linguist-generated
binding.gyp linguist-generated
setup.py linguist-generated
Makefile linguist-generated
Package.swift linguist-generated

38
editor/zed/tree-sitter-jdg/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Rust artifacts
Cargo.lock
target/
# Node artifacts
build/
prebuilds/
node_modules/
*.tgz
# Swift artifacts
.build/
# Go artifacts
go.sum
_obj/
# Python artifacts
.venv/
dist/
*.egg-info
*.whl
# C artifacts
*.a
*.so
*.so.*
*.dylib
*.dll
*.pc
# Example dirs
/examples/*/
# Grammar volatiles
*.wasm
*.obj
*.o

View File

@@ -0,0 +1,342 @@
module.exports = grammar({
name: 'jdg',
extras: $ => [/\s+/, $.comment],
word: $ => $.identifier,
conflicts: $ => [],
rules: {
source_file: $ => repeat($._top_statement),
comment: $ => token(seq('//', /[^\n]*/)),
_top_statement: $ => choice(
$.include,
$.toolchains,
$.build_defaults,
$.structured_build,
$.legacy_build_platform,
$.legacy_build,
$.binary_decl,
$.sources_decl,
$.bool_decl,
$.timeout_decl,
$.memory_limit_decl,
$.group,
),
include: $ => seq(
'include',
field('path', $.string)
),
legacy_build: $ => prec(1, seq(
'build',
field('command', $.string)
)),
legacy_build_platform: $ => seq(
field('keyword', choice('build_linux', 'build_windows', 'build_darwin')),
field('command', $.string)
),
structured_build: $ => prec(2, seq(
'build',
field('name', $.string),
field('body', $.build_block)
)),
build_defaults: $ => seq(
'build_defaults',
field('body', $.build_block)
),
build_block: $ => seq(
'{',
repeat($._build_field),
'}'
),
_build_field: $ => choice(
$.build_scalar_field,
$.build_list_field,
$.build_profile_field,
$.build_warnings_field,
$.define_field,
$.os_override,
),
build_scalar_field: $ => seq(
field('name', choice('language', 'standard', 'output', 'wrapper')),
'=',
field('value', $.string)
),
build_list_field: $ => seq(
field('name', choice('sources', 'includes', 'sanitize', 'link', 'extra', 'platforms', 'compilers')),
'=',
field('value', repeat1($.string))
),
build_profile_field: $ => seq(
'profile',
'=',
field('value', $.profile_value)
),
profile_value: $ => choice('release', 'debug', 'sanitized'),
build_warnings_field: $ => seq(
'warnings',
'=',
field('value', $.warnings_value)
),
warnings_value: $ => choice('default', 'strict', 'pedantic'),
define_field: $ => seq(
'define',
'(',
field('key', $.string),
')',
'=',
field('value', $.string)
),
os_override: $ => seq(
field('os', $.os_name),
field('body', $.build_block)
),
os_name: $ => choice('linux', 'windows', 'darwin'),
toolchains: $ => seq(
'toolchains',
'{',
repeat($.toolchain_entry),
'}'
),
toolchain_entry: $ => seq(
field('name', choice($.identifier, $.string)),
'{',
repeat($._toolchain_field),
'}'
),
_toolchain_field: $ => choice(
$.toolchain_platforms_field,
$.toolchain_binary_field,
$.toolchain_class_field,
),
toolchain_platforms_field: $ => seq(
'platforms',
'=',
field('value', repeat1($.string))
),
toolchain_binary_field: $ => seq(
'binary',
'=',
field('value', $.string)
),
toolchain_class_field: $ => seq(
'class',
'=',
field('value', $.class_value)
),
class_value: $ => choice('gnu', 'msvc'),
binary_decl: $ => seq(
'binary',
'=',
field('value', $.string)
),
sources_decl: $ => seq(
'sources',
'=',
field('value', $.string)
),
bool_decl: $ => seq(
field('name', choice('normalize_crlf', 'trim_trailing_ws')),
'=',
field('value', $.bool)
),
timeout_decl: $ => seq(
'timeout',
field('value', $.duration)
),
memory_limit_decl: $ => seq(
'memory_limit',
'=',
field('value', $.size)
),
group: $ => seq(
'group',
'(',
field('name', $.string),
')',
'{',
repeat($._group_field),
'}'
),
_group_field: $ => choice(
$.group_weight_field,
$.group_timeout_field,
$.group_memory_field,
$.group_scoring_field,
$.group_wrapper_field,
$.env_decl,
$.test,
$.pattern,
),
group_weight_field: $ => seq('weight', '=', field('value', $.number)),
group_timeout_field: $ => seq('timeout', '=', field('value', $.duration)),
group_memory_field: $ => seq('memory_limit', '=', field('value', $.size)),
group_scoring_field: $ => seq('scoring', '=', field('value', $.scoring_value)),
scoring_value: $ => choice('partial', 'all_or_none'),
group_wrapper_field: $ => seq('wrapper', '=', field('value', $.string)),
env_decl: $ => seq(
'env',
'(',
field('key', $.string),
')',
'=',
field('value', $.string)
),
test: $ => seq(
'test',
'(',
field('name', $.string),
')',
'{',
repeat($._test_field),
'}'
),
_test_field: $ => choice(
$.test_stdin_field,
$.test_stdout_field,
$.test_stderr_field,
$.test_args_field,
$.test_exit_code_field,
$.test_timeout_field,
$.test_memory_field,
$.test_wrapper_field,
$.env_decl,
$.file_decl,
$.out_file_decl,
),
test_stdin_field: $ => seq('stdin', '=', field('value', $.string)),
test_stdout_field: $ => seq('stdout', $._matcher),
test_stderr_field: $ => seq('stderr', $._matcher),
test_args_field: $ => seq('args', '=', field('value', repeat1($.string))),
test_exit_code_field: $ => seq('exitCode', '=', field('value', $.integer)),
test_timeout_field: $ => seq('timeout', '=', field('value', $.duration)),
test_memory_field: $ => seq('memory_limit', '=', field('value', $.size)),
test_wrapper_field: $ => seq('wrapper', '=', field('value', $.string)),
file_decl: $ => seq(
'file',
'(',
field('key', $.string),
')',
'=',
field('value', $.string)
),
out_file_decl: $ => seq(
'outFile',
'(',
field('key', $.string),
')',
'=',
field('value', $.string)
),
_matcher: $ => choice(
$.exact_matcher,
$.numeric_matcher,
$.contains_matcher,
$.regex_matcher,
$.any_order_matcher,
),
exact_matcher: $ => seq('=', field('value', $.string)),
numeric_matcher: $ => seq(
'~',
field('epsilon', $.number),
'of',
field('value', $.string)
),
contains_matcher: $ => seq('contains', field('value', $.string)),
regex_matcher: $ => seq('matches', field('pattern', $.regex_string)),
any_order_matcher: $ => seq(
'anyOrder',
'{',
repeat1($.string),
'}'
),
pattern: $ => seq(
'pattern',
'{',
repeat($._pattern_field),
'}'
),
_pattern_field: $ => choice(
$.pattern_input_field,
$.pattern_output_field,
$.pattern_dirs_field,
$.pattern_args_field,
),
pattern_input_field: $ => seq('input', '=', field('value', $.string)),
pattern_output_field: $ => seq('output', '=', field('value', $.string)),
pattern_dirs_field: $ => seq('dirs', '=', field('value', $.string)),
pattern_args_field: $ => seq('args', '=', field('value', repeat1($.string))),
bool: $ => choice('true', 'false'),
number: $ => choice($.integer, $.float),
string: $ => choice(
$.heredoc_string,
$.simple_string,
),
simple_string: $ => token(seq(
'"',
repeat(choice(
/[^"\\\n]/,
seq('\\', /./)
)),
'"'
)),
heredoc_string: $ => token(seq(
'"""',
/([^"]|"[^"]|""[^"])*/,
'"""'
)),
regex_string: $ => $.simple_string,
identifier: $ => /[a-zA-Z_][a-zA-Z0-9_]*/,
integer: $ => token(/-?\d+/),
float: $ => token(/-?\d+\.\d+/),
duration: $ => token(/\d+(ms|s|m)/),
size: $ => token(/\d+(KiB|MiB|GiB|KB|MB|GB|B|K|M|G)/),
}
});

View File

@@ -0,0 +1,54 @@
{
"name": "tree-sitter-jdg",
"version": "0.1.0",
"description": "Tree-sitter grammar for the judge DSL (.jdg)",
"main": "bindings/node",
"types": "bindings/node",
"keywords": [
"tree-sitter",
"parser",
"jdg",
"judge"
],
"files": [
"grammar.js",
"binding.gyp",
"prebuilds/**",
"bindings/node/*",
"queries/*",
"src/**"
],
"license": "MIT",
"dependencies": {
"node-addon-api": "^7.1.0",
"node-gyp-build": "^4.8.0"
},
"peerDependencies": {
"tree-sitter": "^0.21.0"
},
"peerDependenciesMeta": {
"tree_sitter": {
"optional": true
}
},
"devDependencies": {
"tree-sitter-cli": "^0.22.0",
"prebuildify": "^6.0.0"
},
"scripts": {
"build": "tree-sitter generate",
"test": "tree-sitter test",
"install": "node-gyp-build",
"prebuildify": "prebuildify --napi --strip"
},
"tree-sitter": [
{
"scope": "source.jdg",
"file-types": [
"jdg"
],
"highlights": "queries/highlights.scm",
"injections": "queries/injections.scm"
}
]
}

1732
editor/zed/tree-sitter-jdg/src/grammar.json generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

5922
editor/zed/tree-sitter-jdg/src/parser.c generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
#ifndef TREE_SITTER_ALLOC_H_
#define TREE_SITTER_ALLOC_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
// Allow clients to override allocation functions
#ifdef TREE_SITTER_REUSE_ALLOCATOR
extern void *(*ts_current_malloc)(size_t);
extern void *(*ts_current_calloc)(size_t, size_t);
extern void *(*ts_current_realloc)(void *, size_t);
extern void (*ts_current_free)(void *);
#ifndef ts_malloc
#define ts_malloc ts_current_malloc
#endif
#ifndef ts_calloc
#define ts_calloc ts_current_calloc
#endif
#ifndef ts_realloc
#define ts_realloc ts_current_realloc
#endif
#ifndef ts_free
#define ts_free ts_current_free
#endif
#else
#ifndef ts_malloc
#define ts_malloc malloc
#endif
#ifndef ts_calloc
#define ts_calloc calloc
#endif
#ifndef ts_realloc
#define ts_realloc realloc
#endif
#ifndef ts_free
#define ts_free free
#endif
#endif
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_ALLOC_H_

View File

@@ -0,0 +1,290 @@
#ifndef TREE_SITTER_ARRAY_H_
#define TREE_SITTER_ARRAY_H_
#ifdef __cplusplus
extern "C" {
#endif
#include "./alloc.h"
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#ifdef _MSC_VER
#pragma warning(disable : 4101)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-variable"
#endif
#define Array(T) \
struct { \
T *contents; \
uint32_t size; \
uint32_t capacity; \
}
/// Initialize an array.
#define array_init(self) \
((self)->size = 0, (self)->capacity = 0, (self)->contents = NULL)
/// Create an empty array.
#define array_new() \
{ NULL, 0, 0 }
/// Get a pointer to the element at a given `index` in the array.
#define array_get(self, _index) \
(assert((uint32_t)(_index) < (self)->size), &(self)->contents[_index])
/// Get a pointer to the first element in the array.
#define array_front(self) array_get(self, 0)
/// Get a pointer to the last element in the array.
#define array_back(self) array_get(self, (self)->size - 1)
/// Clear the array, setting its size to zero. Note that this does not free any
/// memory allocated for the array's contents.
#define array_clear(self) ((self)->size = 0)
/// Reserve `new_capacity` elements of space in the array. If `new_capacity` is
/// less than the array's current capacity, this function has no effect.
#define array_reserve(self, new_capacity) \
_array__reserve((Array *)(self), array_elem_size(self), new_capacity)
/// Free any memory allocated for this array. Note that this does not free any
/// memory allocated for the array's contents.
#define array_delete(self) _array__delete((Array *)(self))
/// Push a new `element` onto the end of the array.
#define array_push(self, element) \
(_array__grow((Array *)(self), 1, array_elem_size(self)), \
(self)->contents[(self)->size++] = (element))
/// Increase the array's size by `count` elements.
/// New elements are zero-initialized.
#define array_grow_by(self, count) \
do { \
if ((count) == 0) break; \
_array__grow((Array *)(self), count, array_elem_size(self)); \
memset((self)->contents + (self)->size, 0, (count) * array_elem_size(self)); \
(self)->size += (count); \
} while (0)
/// Append all elements from one array to the end of another.
#define array_push_all(self, other) \
array_extend((self), (other)->size, (other)->contents)
/// Append `count` elements to the end of the array, reading their values from the
/// `contents` pointer.
#define array_extend(self, count, contents) \
_array__splice( \
(Array *)(self), array_elem_size(self), (self)->size, \
0, count, contents \
)
/// Remove `old_count` elements from the array starting at the given `index`. At
/// the same index, insert `new_count` new elements, reading their values from the
/// `new_contents` pointer.
#define array_splice(self, _index, old_count, new_count, new_contents) \
_array__splice( \
(Array *)(self), array_elem_size(self), _index, \
old_count, new_count, new_contents \
)
/// Insert one `element` into the array at the given `index`.
#define array_insert(self, _index, element) \
_array__splice((Array *)(self), array_elem_size(self), _index, 0, 1, &(element))
/// Remove one element from the array at the given `index`.
#define array_erase(self, _index) \
_array__erase((Array *)(self), array_elem_size(self), _index)
/// Pop the last element off the array, returning the element by value.
#define array_pop(self) ((self)->contents[--(self)->size])
/// Assign the contents of one array to another, reallocating if necessary.
#define array_assign(self, other) \
_array__assign((Array *)(self), (const Array *)(other), array_elem_size(self))
/// Swap one array with another
#define array_swap(self, other) \
_array__swap((Array *)(self), (Array *)(other))
/// Get the size of the array contents
#define array_elem_size(self) (sizeof *(self)->contents)
/// Search a sorted array for a given `needle` value, using the given `compare`
/// callback to determine the order.
///
/// If an existing element is found to be equal to `needle`, then the `index`
/// out-parameter is set to the existing value's index, and the `exists`
/// out-parameter is set to true. Otherwise, `index` is set to an index where
/// `needle` should be inserted in order to preserve the sorting, and `exists`
/// is set to false.
#define array_search_sorted_with(self, compare, needle, _index, _exists) \
_array__search_sorted(self, 0, compare, , needle, _index, _exists)
/// Search a sorted array for a given `needle` value, using integer comparisons
/// of a given struct field (specified with a leading dot) to determine the order.
///
/// See also `array_search_sorted_with`.
#define array_search_sorted_by(self, field, needle, _index, _exists) \
_array__search_sorted(self, 0, _compare_int, field, needle, _index, _exists)
/// Insert a given `value` into a sorted array, using the given `compare`
/// callback to determine the order.
#define array_insert_sorted_with(self, compare, value) \
do { \
unsigned _index, _exists; \
array_search_sorted_with(self, compare, &(value), &_index, &_exists); \
if (!_exists) array_insert(self, _index, value); \
} while (0)
/// Insert a given `value` into a sorted array, using integer comparisons of
/// a given struct field (specified with a leading dot) to determine the order.
///
/// See also `array_search_sorted_by`.
#define array_insert_sorted_by(self, field, value) \
do { \
unsigned _index, _exists; \
array_search_sorted_by(self, field, (value) field, &_index, &_exists); \
if (!_exists) array_insert(self, _index, value); \
} while (0)
// Private
typedef Array(void) Array;
/// This is not what you're looking for, see `array_delete`.
static inline void _array__delete(Array *self) {
if (self->contents) {
ts_free(self->contents);
self->contents = NULL;
self->size = 0;
self->capacity = 0;
}
}
/// This is not what you're looking for, see `array_erase`.
static inline void _array__erase(Array *self, size_t element_size,
uint32_t index) {
assert(index < self->size);
char *contents = (char *)self->contents;
memmove(contents + index * element_size, contents + (index + 1) * element_size,
(self->size - index - 1) * element_size);
self->size--;
}
/// This is not what you're looking for, see `array_reserve`.
static inline void _array__reserve(Array *self, size_t element_size, uint32_t new_capacity) {
if (new_capacity > self->capacity) {
if (self->contents) {
self->contents = ts_realloc(self->contents, new_capacity * element_size);
} else {
self->contents = ts_malloc(new_capacity * element_size);
}
self->capacity = new_capacity;
}
}
/// This is not what you're looking for, see `array_assign`.
static inline void _array__assign(Array *self, const Array *other, size_t element_size) {
_array__reserve(self, element_size, other->size);
self->size = other->size;
memcpy(self->contents, other->contents, self->size * element_size);
}
/// This is not what you're looking for, see `array_swap`.
static inline void _array__swap(Array *self, Array *other) {
Array swap = *other;
*other = *self;
*self = swap;
}
/// This is not what you're looking for, see `array_push` or `array_grow_by`.
static inline void _array__grow(Array *self, uint32_t count, size_t element_size) {
uint32_t new_size = self->size + count;
if (new_size > self->capacity) {
uint32_t new_capacity = self->capacity * 2;
if (new_capacity < 8) new_capacity = 8;
if (new_capacity < new_size) new_capacity = new_size;
_array__reserve(self, element_size, new_capacity);
}
}
/// This is not what you're looking for, see `array_splice`.
static inline void _array__splice(Array *self, size_t element_size,
uint32_t index, uint32_t old_count,
uint32_t new_count, const void *elements) {
uint32_t new_size = self->size + new_count - old_count;
uint32_t old_end = index + old_count;
uint32_t new_end = index + new_count;
assert(old_end <= self->size);
_array__reserve(self, element_size, new_size);
char *contents = (char *)self->contents;
if (self->size > old_end) {
memmove(
contents + new_end * element_size,
contents + old_end * element_size,
(self->size - old_end) * element_size
);
}
if (new_count > 0) {
if (elements) {
memcpy(
(contents + index * element_size),
elements,
new_count * element_size
);
} else {
memset(
(contents + index * element_size),
0,
new_count * element_size
);
}
}
self->size += new_count - old_count;
}
/// A binary search routine, based on Rust's `std::slice::binary_search_by`.
/// This is not what you're looking for, see `array_search_sorted_with` or `array_search_sorted_by`.
#define _array__search_sorted(self, start, compare, suffix, needle, _index, _exists) \
do { \
*(_index) = start; \
*(_exists) = false; \
uint32_t size = (self)->size - *(_index); \
if (size == 0) break; \
int comparison; \
while (size > 1) { \
uint32_t half_size = size / 2; \
uint32_t mid_index = *(_index) + half_size; \
comparison = compare(&((self)->contents[mid_index] suffix), (needle)); \
if (comparison <= 0) *(_index) = mid_index; \
size -= half_size; \
} \
comparison = compare(&((self)->contents[*(_index)] suffix), (needle)); \
if (comparison == 0) *(_exists) = true; \
else if (comparison < 0) *(_index) += 1; \
} while (0)
/// Helper macro for the `_sorted_by` routines below. This takes the left (existing)
/// parameter by reference in order to work with the generic sorting function above.
#define _compare_int(a, b) ((int)*(a) - (int)(b))
#ifdef _MSC_VER
#pragma warning(default : 4101)
#elif defined(__GNUC__) || defined(__clang__)
#pragma GCC diagnostic pop
#endif
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_ARRAY_H_

View File

@@ -0,0 +1,265 @@
#ifndef TREE_SITTER_PARSER_H_
#define TREE_SITTER_PARSER_H_
#ifdef __cplusplus
extern "C" {
#endif
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#define ts_builtin_sym_error ((TSSymbol)-1)
#define ts_builtin_sym_end 0
#define TREE_SITTER_SERIALIZATION_BUFFER_SIZE 1024
#ifndef TREE_SITTER_API_H_
typedef uint16_t TSStateId;
typedef uint16_t TSSymbol;
typedef uint16_t TSFieldId;
typedef struct TSLanguage TSLanguage;
#endif
typedef struct {
TSFieldId field_id;
uint8_t child_index;
bool inherited;
} TSFieldMapEntry;
typedef struct {
uint16_t index;
uint16_t length;
} TSFieldMapSlice;
typedef struct {
bool visible;
bool named;
bool supertype;
} TSSymbolMetadata;
typedef struct TSLexer TSLexer;
struct TSLexer {
int32_t lookahead;
TSSymbol result_symbol;
void (*advance)(TSLexer *, bool);
void (*mark_end)(TSLexer *);
uint32_t (*get_column)(TSLexer *);
bool (*is_at_included_range_start)(const TSLexer *);
bool (*eof)(const TSLexer *);
};
typedef enum {
TSParseActionTypeShift,
TSParseActionTypeReduce,
TSParseActionTypeAccept,
TSParseActionTypeRecover,
} TSParseActionType;
typedef union {
struct {
uint8_t type;
TSStateId state;
bool extra;
bool repetition;
} shift;
struct {
uint8_t type;
uint8_t child_count;
TSSymbol symbol;
int16_t dynamic_precedence;
uint16_t production_id;
} reduce;
uint8_t type;
} TSParseAction;
typedef struct {
uint16_t lex_state;
uint16_t external_lex_state;
} TSLexMode;
typedef union {
TSParseAction action;
struct {
uint8_t count;
bool reusable;
} entry;
} TSParseActionEntry;
typedef struct {
int32_t start;
int32_t end;
} TSCharacterRange;
struct TSLanguage {
uint32_t version;
uint32_t symbol_count;
uint32_t alias_count;
uint32_t token_count;
uint32_t external_token_count;
uint32_t state_count;
uint32_t large_state_count;
uint32_t production_id_count;
uint32_t field_count;
uint16_t max_alias_sequence_length;
const uint16_t *parse_table;
const uint16_t *small_parse_table;
const uint32_t *small_parse_table_map;
const TSParseActionEntry *parse_actions;
const char * const *symbol_names;
const char * const *field_names;
const TSFieldMapSlice *field_map_slices;
const TSFieldMapEntry *field_map_entries;
const TSSymbolMetadata *symbol_metadata;
const TSSymbol *public_symbol_map;
const uint16_t *alias_map;
const TSSymbol *alias_sequences;
const TSLexMode *lex_modes;
bool (*lex_fn)(TSLexer *, TSStateId);
bool (*keyword_lex_fn)(TSLexer *, TSStateId);
TSSymbol keyword_capture_token;
struct {
const bool *states;
const TSSymbol *symbol_map;
void *(*create)(void);
void (*destroy)(void *);
bool (*scan)(void *, TSLexer *, const bool *symbol_whitelist);
unsigned (*serialize)(void *, char *);
void (*deserialize)(void *, const char *, unsigned);
} external_scanner;
const TSStateId *primary_state_ids;
};
static inline bool set_contains(TSCharacterRange *ranges, uint32_t len, int32_t lookahead) {
uint32_t index = 0;
uint32_t size = len - index;
while (size > 1) {
uint32_t half_size = size / 2;
uint32_t mid_index = index + half_size;
TSCharacterRange *range = &ranges[mid_index];
if (lookahead >= range->start && lookahead <= range->end) {
return true;
} else if (lookahead > range->end) {
index = mid_index;
}
size -= half_size;
}
TSCharacterRange *range = &ranges[index];
return (lookahead >= range->start && lookahead <= range->end);
}
/*
* Lexer Macros
*/
#ifdef _MSC_VER
#define UNUSED __pragma(warning(suppress : 4101))
#else
#define UNUSED __attribute__((unused))
#endif
#define START_LEXER() \
bool result = false; \
bool skip = false; \
UNUSED \
bool eof = false; \
int32_t lookahead; \
goto start; \
next_state: \
lexer->advance(lexer, skip); \
start: \
skip = false; \
lookahead = lexer->lookahead;
#define ADVANCE(state_value) \
{ \
state = state_value; \
goto next_state; \
}
#define ADVANCE_MAP(...) \
{ \
static const uint16_t map[] = { __VA_ARGS__ }; \
for (uint32_t i = 0; i < sizeof(map) / sizeof(map[0]); i += 2) { \
if (map[i] == lookahead) { \
state = map[i + 1]; \
goto next_state; \
} \
} \
}
#define SKIP(state_value) \
{ \
skip = true; \
state = state_value; \
goto next_state; \
}
#define ACCEPT_TOKEN(symbol_value) \
result = true; \
lexer->result_symbol = symbol_value; \
lexer->mark_end(lexer);
#define END_STATE() return result;
/*
* Parse Table Macros
*/
#define SMALL_STATE(id) ((id) - LARGE_STATE_COUNT)
#define STATE(id) id
#define ACTIONS(id) id
#define SHIFT(state_value) \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.state = (state_value) \
} \
}}
#define SHIFT_REPEAT(state_value) \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.state = (state_value), \
.repetition = true \
} \
}}
#define SHIFT_EXTRA() \
{{ \
.shift = { \
.type = TSParseActionTypeShift, \
.extra = true \
} \
}}
#define REDUCE(symbol_name, children, precedence, prod_id) \
{{ \
.reduce = { \
.type = TSParseActionTypeReduce, \
.symbol = symbol_name, \
.child_count = children, \
.dynamic_precedence = precedence, \
.production_id = prod_id \
}, \
}}
#define RECOVER() \
{{ \
.type = TSParseActionTypeRecover \
}}
#define ACCEPT_INPUT() \
{{ \
.type = TSParseActionTypeAccept \
}}
#ifdef __cplusplus
}
#endif
#endif // TREE_SITTER_PARSER_H_

View 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
View 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"
}
}

35
example/mem-limit/mem.jdg Normal file
View File

@@ -0,0 +1,35 @@
// End-to-end check that memory_limit is actually enforced by the runner.
// The solution allocates N MiB (argv[1]) and touches every page.
//
// - "within_limit" allocates 16 MiB under a 256 MiB cap → expected PASS
// - "exceeds_limit" allocates 256 MiB under a 64 MiB cap → expected MLE
//
// The workflow runs judge --json and asserts these statuses via jq.
build "$CC -O2 -std=c11 -Wall -Wextra solution.c -o solution"
build_windows "if /I \"%CC%\"==\"msvc\" (cl /nologo /O2 /W3 solution.c /Fe:solution.exe) else (%CC% -O2 -std=c11 -Wall -Wextra solution.c -o solution.exe)"
binary = "solution"
timeout 10s
normalize_crlf = true
group("within_limit") {
weight = 1.0
memory_limit = 256M
test("allocate_16mb") {
args = "16"
stdout = "ok 16\n"
}
}
group("exceeds_limit") {
weight = 0.0 // score doesn't matter — we assert the status in CI
memory_limit = 64M
test("allocate_256mb") {
args = "256"
// no stdout matcher: process is expected to be killed before it prints
}
}

View File

@@ -0,0 +1,27 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, char **argv) {
if (argc < 2) {
fprintf(stderr, "usage: %s <megabytes>\n", argv[0]);
return 1;
}
long mb = strtol(argv[1], NULL, 10);
if (mb <= 0) return 1;
size_t bytes = (size_t)mb * 1024 * 1024;
char *p = (char *)malloc(bytes);
if (!p) {
fprintf(stderr, "malloc failed\n");
return 2;
}
for (size_t i = 0; i < bytes; i += 4096) {
p[i] = (char)(i & 0xff);
}
volatile char sink = p[bytes - 1];
(void)sink;
printf("ok %ld\n", mb);
return 0;
}

View File

@@ -0,0 +1,23 @@
// Минимальный рабочий suite на structured DSL.
// Декларируем один toolchain, один build, одну группу с одним тестом.
// С этого можно копипастить для новых задач.
toolchains {
gcc { platforms = "linux" }
}
build "release" {
language = "c"
standard = "c11"
sources = "solution.c"
output = "solution"
profile = release
}
group("basic") {
weight = 1.0
test("hello") {
stdout = "Hello, World!\n"
}
}

View File

@@ -0,0 +1,72 @@
// Полный showcase возможностей build-блока.
// Показывает: toolchains c платформами / переопределением binary / явным class,
// build_defaults как общая основа, три профиля (release/debug/sanitized),
// фильтры platforms и compilers, OS-оверрайды, wrapper, sanitize, define,
// includes, link, extra, а также build-level `wrapper` для valgrind.
toolchains {
gcc { platforms = "linux" }
clang {
platforms = "linux" "windows"
binary = "clang-17" // конкретная версия в PATH
class = gnu // явный class если имя нестандартное
}
msvc { platforms = "windows" }
nvcc {
platforms = "linux"
class = gnu // nvcc принимает gnu-like флаги
}
}
build_defaults {
language = "c"
standard = "c11"
sources = "src/*.c" // glob, раскрывается относительно work dir
output = "solution" // на Windows автоматически станет solution.exe
includes = "include" "vendor/include"
link = "m" "pthread"
warnings = strict // → -Wall -Wextra / /W4
define("BUILD_ID") = "42"
}
build "release" {
profile = release // → -O2 / /O2
// OS-специфичные правки поверх defaults. Скаляры переопределяются,
// списки (extra, sanitize, sources, includes) аккумулируются.
linux { extra = "-fPIC" }
windows { extra = "/bigobj" }
darwin { extra = "-mmacosx-version-min=11" }
}
build "debug" {
profile = debug // → -O0 -g / /Od /Zi
warnings = pedantic // → добавляет -Wpedantic / /permissive-
define("DEBUG") = "1"
}
build "sanitized" {
profile = sanitized
sanitize = "address" "undefined" "leak"
platforms = "linux" // UBSan не работает на MSVC
compilers = "gcc" "clang" // и только gnu-like
extra = "-fno-sanitize-recover=all"
}
build "debug-valgrind" {
profile = debug
wrapper = "valgrind --error-exitcode=99 --leak-check=full -q"
platforms = "linux"
compilers = "gcc"
}
timeout 10s
memory_limit = 256M
group("basic") {
weight = 1.0
test("smoke") {
stdout = "ok\n"
}
}

View File

@@ -0,0 +1,69 @@
// Все способы сматчить stdout/stderr: exact, contains, regex, anyOrder,
// numeric-eps. Плюс случай, когда сравнение вывода вообще не нужно
// (достаточно exitCode).
toolchains {
gcc { platforms = "linux" }
}
build "release" {
language = "c"
standard = "c11"
sources = "solution.c"
output = "solution"
profile = release
}
group("matchers") {
weight = 1.0
// Точное совпадение (по умолчанию). Пробелы, переводы строк и
// регистр важны. Нормализация CRLF / trim_ws настраивается на
// уровне файла.
test("exact") {
stdin = "42\n"
stdout = "42\n"
}
// Подстрока в любом месте вывода.
test("contains") {
stdin = "log me\n"
stdout contains "log me"
}
// Регулярное выражение (Go regexp syntax).
test("regex") {
stdin = "7\n"
stdout matches "^result:\\s+[0-9]+\\s*$"
}
// Лайны могут прийти в любом порядке — полезно для stress-тестов
// с параллельным выводом.
test("any-order") {
stdin = "start\n"
stdout anyOrder {
"alpha"
"beta"
"gamma"
}
}
// Числовое сравнение с epsilon — для задач с плавающей точкой.
// Синтаксис: ~ <float> of "<ожидаемое значение как строка>"
test("numeric") {
stdin = "3.14159\n"
stdout ~ 0.0001 of "3.14159\n"
}
// Проверяем только stderr; stdout не трогаем вообще (NoMatcher).
test("stderr-only") {
args = "--version"
stderr contains "version 1."
}
// Никакого матчинга вывода — нас интересует только exit code.
test("exit-code-only") {
args = "--help"
exitCode = 0
}
}

View File

@@ -0,0 +1,77 @@
// Тонкая настройка: timeout и memory_limit на разных уровнях (file → group
// → test), режимы скоринга (partial / all_or_none), переменные окружения,
// проверка exit code, передача аргументов.
toolchains {
gcc { platforms = "linux" }
}
build "release" {
language = "c"
standard = "c11"
sources = "solution.c"
output = "solution"
profile = release
}
// Глобальные дефолты. Применяются к каждому тесту, если тот не
// переопределит у себя или на уровне группы.
timeout 5s
memory_limit = 256M
normalize_crlf = true // стрип \r перед сравнением — полезно на Windows
trim_trailing_ws = true // trim trailing space/tab на каждой строке
group("strict-subset") {
weight = 0.4
scoring = all_or_none // группа даёт weight или 0 — без частичного
// Эти дефолты применятся к каждому тесту группы.
timeout = 2s
memory_limit = 64M
env("LC_ALL") = "C"
env("LANG") = "C"
test("fast") {
args = "--mode" "fast"
stdout = "ok\n"
}
test("strict-timeout") {
timeout = 500ms // переопределяет group timeout
stdout = "ok\n"
}
test("high-memory") {
memory_limit = 128M // переопределяет group memory_limit
stdout = "ok\n"
}
test("own-env") {
env("FOO") = "bar" // добавляется к group env
stdout = "ok\n"
}
}
group("partial-credit") {
weight = 0.6
scoring = partial // default: weight * passed/total
test("case-1") {
stdin = "1\n"
stdout = "1\n"
exitCode = 0
}
test("case-2") {
stdin = "2\n"
stdout = "4\n"
exitCode = 0
}
test("case-error") {
// Программа ДОЛЖНА выйти с кодом 1 на плохом вводе.
stdin = "abc\n"
exitCode = 1
stderr contains "invalid input"
}
}

View File

@@ -0,0 +1,80 @@
// Автогенерация тестов из файлов/папок. Покрывает четыре типовых сценария:
//
// 1. Stdio-режим с парой globs — классика: stdin из .in, stdout = .ans
// 2. Shared expected — N входов, один общий ожидаемый файл
// 3. File-mode по args — программа читает файл по аргументу
// 4. Dir-mode с input+output файлами — программа читает И пишет файлы
//
// Плейсхолдеры в `args` pattern'а:
// {input_path} — путь к input-файлу текущего теста (для glob — разный,
// для литерала — один и тот же во всех тестах)
// {output_path} — аналогично для output
// {name} — wildcard-часть имени (glob) или basename директории (dir)
// {dir} — dir-mode: полный путь к директории теста
//
// Правило режима: наличие `{input_path}` в args → input кладётся как InFile,
// а не в stdin. Наличие `{output_path}` → expected сравнивается как OutFile,
// а не stdout.
toolchains {
gcc { platforms = "linux" }
}
build "release" {
language = "c"
standard = "c11"
sources = "solution.c"
output = "solution"
profile = release
}
// 1) Классика: каждая пара (*.in, *.ans) → один тест.
// Stdin ← .in, Stdout сравнивается с .ans.
group("stdio-pair") {
weight = 0.25
pattern {
input = "tests/*.in"
output = "tests/*.ans"
}
}
// 2) Один общий expected для всех входов.
// Удобно для задач где «для любого корректного входа ответ одинаковый»
// (edge-case fuzzing) или наоборот «все некорректные входы → error»
// (смотрим на один и тот же файл с сообщением об ошибке).
group("shared-output") {
weight = 0.25
pattern {
input = "edge/*.in"
output = "edge/expected.ans" // литерал без *
}
}
// 3) Программа читает файл по аргументу командной строки, а не из stdin.
// Expander создаёт InFiles[01.in]=..., запускает `./solution 01.in`,
// сравнивает stdout программы с содержимым соответствующего .ans.
group("file-input") {
weight = 0.25
pattern {
input = "file-tests/*.in"
output = "file-tests/*.ans"
args = "{input_path}"
}
}
// 4) Программа и читает, и пишет через файлы — никакого stdin/stdout.
// Expander кладёт input в InFiles, expected в OutFiles, запускает
// `./solution in.txt out.txt` и сравнивает созданный out.txt с expected.
group("file-io") {
weight = 0.25
pattern {
dirs = "cases/*" // каждая подпапка — отдельный тест
input = "in.txt"
output = "out.txt"
args = "{input_path}" "{output_path}"
}
}

View File

@@ -0,0 +1,59 @@
// Тесты, которые общаются через файлы, а не через stdin/stdout.
// file("name") = "content" — файл кладётся в рабочую директорию теста
// перед запуском программы.
// outFile("name") = "content" — после запуска содержимое файла сравнивается
// с ожидаемым (точное совпадение с учётом
// normalize_crlf / trim_trailing_ws).
toolchains {
gcc { platforms = "linux" }
}
build "release" {
language = "c"
standard = "c11"
sources = "solution.c"
output = "solution"
profile = release
}
normalize_crlf = true
trim_trailing_ws = true
group("io") {
weight = 1.0
// Программа читает input.txt, пишет output.txt.
test("copy") {
args = "input.txt" "output.txt"
file("input.txt") = "hello world\n"
outFile("output.txt") = "hello world\n"
}
// Несколько входных и выходных файлов сразу — удобно для задач
// вроде «посчитать сумму по каждой строке и записать в отдельный
// файл».
test("multi-file") {
args = "a.txt" "b.txt" "result.txt"
file("a.txt") = "1 2 3\n"
file("b.txt") = "10 20 30\n"
outFile("result.txt") = "11 22 33\n"
}
// Вложенные пути тоже работают — создаются автоматически.
test("nested") {
args = "data/in.txt" "data/out.txt"
file("data/in.txt") = "payload\n"
outFile("data/out.txt") = "PAYLOAD\n"
}
// Можно комбинировать с stdin/stdout и переменными окружения.
test("combined") {
stdin = "42\n"
args = "config.ini"
env("MODE") = "strict"
file("config.ini") = "threshold=10\n"
stdout = "ok\n"
outFile("log.txt") = "processed 42\n"
}
}

View File

@@ -0,0 +1,41 @@
// Наследование общей конфигурации через `include`. Подключает соседний
// common.jdg (тулчейны + build_defaults + глобальные настройки), а затем
// задаёт только специфичные для этой задачи build-варианты и тесты.
//
// Пути в include относительны самому этому файлу (не CWD, не корню репо).
// Абсолютные пути тоже работают. Циклические включения детектятся парсером.
//
// Локальные директивы поверх include могут переопределять:
// - скаляры в build_defaults (через последующий build_defaults-блок)
// - timeout / memory_limit / binary / sources / normalize_crlf / trim_trailing_ws
// Но не могут переопределять:
// - toolchains с тем же именем (ошибка «duplicate toolchain»)
// - build с тем же именем
// - group с тем же именем
include "common.jdg"
build "release" {
profile = release
}
build "debug" {
profile = debug
warnings = pedantic
}
build "sanitized" {
profile = sanitized
sanitize = "address" "undefined"
platforms = "linux"
compilers = "gcc" "clang"
}
group("basic") {
weight = 1.0
test("smoke") {
stdin = "1\n42\n"
stdout = "42\n"
}
}

View File

@@ -0,0 +1,22 @@
// Общая инфраструктура для всех задач курса: тулчейны и базовые настройки
// билда. Подключается через `include "common.jdg"` из конкретных .jdg
// файлов задач. Локальные build-блоки наследуют эти дефолты через
// встроенный merge в build_defaults.
toolchains {
gcc { platforms = "linux" }
clang { platforms = "linux" "windows" }
msvc { platforms = "windows" }
}
build_defaults {
language = "c"
standard = "c11"
sources = "solution.c"
output = "solution"
warnings = strict
}
timeout 5s
normalize_crlf = true
trim_trailing_ws = true

2
go.mod
View File

@@ -1,3 +1,5 @@
module github.com/Mond1c/judge
go 1.26.1
require golang.org/x/sys v0.27.0

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=

View File

@@ -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
@@ -34,8 +71,15 @@ func Text(w io.Writer, result *runner.SuiteResult) {
if tr.Status != runner.StatusPass {
icon = "✗"
}
fmt.Fprintf(w, "│ %s [%s] %s (%dms)\n",
icon, tr.Status, tr.Name, tr.Elapsed.Milliseconds())
mem := ""
if tr.PeakMemory > 0 {
mem = fmt.Sprintf(", %s", humanBytes(tr.PeakMemory))
if tr.MemoryLimit > 0 {
mem = fmt.Sprintf(", %s/%s", humanBytes(tr.PeakMemory), humanBytes(tr.MemoryLimit))
}
}
fmt.Fprintf(w, "│ %s [%s] %s (%dms%s)\n",
icon, tr.Status, tr.Name, tr.Elapsed.Milliseconds(), mem)
for _, f := range tr.Failures {
for _, line := range strings.Split(f, "\n") {
@@ -46,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 {
@@ -61,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"`
@@ -71,10 +136,68 @@ type jsonGroupResult struct {
}
type jsonTestResult struct {
Name string `json:"name"`
Status string `json:"status"`
ElapsedMs int64 `json:"elapsed_ms"`
Failures []string `json:"failures,omitempty"`
Name string `json:"name"`
Status string `json:"status"`
ElapsedMs int64 `json:"elapsed_ms"`
PeakMemoryKB int64 `json:"peak_memory_kb,omitempty"`
MemoryLimitKB int64 `json:"memory_limit_kb,omitempty"`
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 {
@@ -96,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
}
}
@@ -124,35 +247,38 @@ 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 jsonResult(r *runner.SuiteResult) jsonSuiteResult {
res := jsonSuiteResult{
TotalScore: r.TotalScore,
BuildLog: r.BuildLog,
func extractTotalScore(data []byte) (float64, error) {
var header struct {
TotalScore float64 `json:"total_score"`
}
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(),
Failures: tr.Failures,
})
}
res.Groups = append(res.Groups, jgr)
if err := json.Unmarshal(data, &header); err != nil {
return 0, err
}
return header.TotalScore, nil
}
func humanBytes(n int64) string {
const (
KiB = 1024
MiB = 1024 * KiB
GiB = 1024 * MiB
)
switch {
case n >= GiB:
return fmt.Sprintf("%.2fGiB", float64(n)/float64(GiB))
case n >= MiB:
return fmt.Sprintf("%.1fMiB", float64(n)/float64(MiB))
case n >= KiB:
return fmt.Sprintf("%.0fKiB", float64(n)/float64(KiB))
default:
return fmt.Sprintf("%dB", n)
}
return res
}

198
runner/compiler.go Normal file
View File

@@ -0,0 +1,198 @@
package runner
import (
"fmt"
"runtime"
"slices"
"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 != "" {
switch cfg.Standard {
case "c11":
cfg.Standard = "c17"
case "c23":
cfg.Standard = "clatest"
case "c++23":
cfg.Standard = "c++23preview"
}
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 slices.Contains(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
}

294
runner/compiler_test.go Normal file
View File

@@ -0,0 +1,294 @@
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 range 20 {
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")
// INFO: because we do not have c11 in msvc, i make it c17 (maybe think about that in the future, also maybe print some warning about that)
want := []string{"cl", "/nologo", "/std:c17", "/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,86 @@ func expandPattern(pattern *dsl.Pattern) ([]*dsl.Test, error) {
return expandGlobPattern(pattern)
}
func expandGlobPattern(pattern *dsl.Pattern) ([]*dsl.Test, error) {
inputFiles, err := filepath.Glob(pattern.InputGlob)
type patternCase struct {
name string
inputPath string
outputPath string
dir string
}
func globWithAffixes(pattern string) ([]string, string, string, error) {
files, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("invalid input glob %q: %w", pattern.InputGlob, err)
return nil, "", "", fmt.Errorf("invalid glob %q: %w", pattern, err)
}
if len(inputFiles) == 0 {
return nil, fmt.Errorf("no files matched input glob %q", pattern.InputGlob)
if len(files) == 0 {
return nil, "", "", fmt.Errorf("no files matched glob %q", pattern)
}
prefix, suffix := splitGlob(pattern)
return files, prefix, suffix, nil
}
func expandGlobPattern(pattern *dsl.Pattern) ([]*dsl.Test, error) {
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 !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)
// TODO: i know that this is copypaste, but i do not want make clousers or ifs inside cycle for now
switch {
case inputIsGlob && outputIsGlob:
inputFiles, inputPrefix, inputSuffix, err := globWithAffixes(pattern.InputGlob)
if err != nil {
return nil, fmt.Errorf("read input %q: %w", inputPath, err)
return nil, err
}
outputContent, err := os.ReadFile(outputPath)
if err != nil {
return nil, fmt.Errorf("read output %q: %w", outputPath, err)
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, inputPrefix, inputSuffix, err := globWithAffixes(pattern.InputGlob)
if err != nil {
return nil, err
}
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, outputPrefix, outputSuffix, err := globWithAffixes(pattern.OutputGlob)
if err != nil {
return nil, err
}
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 +107,102 @@ 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
}
inputPath := filepath.Join(dir, pattern.InputFile)
outputPath := filepath.Join(dir, pattern.OutputFile)
inputContent, err := os.ReadFile(inputPath)
if err != nil {
return nil, fmt.Errorf("read %q: %w", inputPath, err)
}
outputContent, err := os.ReadFile(outputPath)
if err != nil {
return nil, fmt.Errorf("read %q: %w", 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,
Env: map[string]string{},
InFiles: map[string]string{},
OutFiles: map[string]string{},
Stdout: dsl.ExactMatcher{Value: expected},
Stderr: dsl.NoMatcher{},
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)
}
func buildTest(c *patternCase, argTemplate []string, useInputAsFile, useOutputAsFile bool) (*dsl.Test, error) {
inputContent, err := os.ReadFile(c.inputPath)
if err != nil {
return nil, fmt.Errorf("read input %q: %w", c.inputPath, err)
}
outputContent, err := os.ReadFile(c.outputPath)
if err != nil {
return nil, fmt.Errorf("read output %q: %w", c.outputPath, err)
}
t := &dsl.Test{
Name: fmt.Sprintf("pattern:%s", c.name),
Env: map[string]string{},
InFiles: map[string]string{},
OutFiles: map[string]string{},
Stderr: dsl.NoMatcher{},
}
inputName := filepath.Base(c.inputPath)
outputName := filepath.Base(c.outputPath)
if useInputAsFile {
t.SetInputFile(inputName, inputContent)
} else {
t.SetStdin(inputContent)
}
if useOutputAsFile {
t.SetOutputFile(outputName, outputContent)
} else {
t.SetStdout(outputContent)
}
if len(argTemplate) > 0 {
t.Args = substituteArgs(argTemplate, map[string]string{
"{input_path}": inputName,
"{output_path}": outputName,
"{name}": c.name,
"{dir}": c.dir,
})
}
return t, nil
}
func buildTests(cases []patternCase, argTemplate []string) ([]*dsl.Test, error) {
useInputAsFile := argsContain(argTemplate, "{input_path}")
useOutputAsFile := argsContain(argTemplate, "{output_path}")
var tests []*dsl.Test
for _, c := range cases {
t, err := buildTest(&c, argTemplate, useInputAsFile, useOutputAsFile)
if err != nil {
return nil, err
}
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")
}
}

15
runner/limiter.go Normal file
View File

@@ -0,0 +1,15 @@
package runner
import "os/exec"
type limiter interface {
prepare(cmd *exec.Cmd) error
afterStart(cmd *exec.Cmd) error
collect() limitStats
cleanup()
}
type limitStats struct {
PeakMemory int64
MemoryExceeded bool
}

228
runner/limiter_linux.go Normal file
View File

@@ -0,0 +1,228 @@
//go:build linux
package runner
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
var (
cgroupRootOnce sync.Once
cgroupRoot string
cgroupRootOwned bool
cgroupInitErr error
cgroupCounter int64
)
const cgroupFSRoot = "/sys/fs/cgroup"
func ensureCgroupRoot() (string, error) {
cgroupRootOnce.Do(func() {
if root, err := createOwnedCgroup(); err == nil {
cgroupRoot = root
cgroupRootOwned = true
return
}
root, err := createScopeCgroup()
if err != nil {
cgroupInitErr = err
return
}
cgroupRoot = root
})
return cgroupRoot, cgroupInitErr
}
func hasController(path, name string) (bool, error) {
data, err := os.ReadFile(path)
if err != nil {
return false, err
}
needle := " " + name + " "
return strings.Contains(" "+strings.TrimSpace(string(data))+" ", needle), nil
}
func createOwnedCgroup() (string, error) {
ok, err := hasController(filepath.Join(cgroupFSRoot, "cgroup.controllers"), "memory")
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("memory controller not available in root cgroup")
}
enabled, err := hasController(filepath.Join(cgroupFSRoot, "cgroup.subtree_control"), "memory")
if err != nil {
return "", err
}
if !enabled {
if err := os.WriteFile(filepath.Join(cgroupFSRoot, "cgroup.subtree_control"), []byte("+memory"), 0644); err != nil {
return "", fmt.Errorf("enable +memory in root subtree_control: %w", err)
}
}
name := fmt.Sprintf("judge.%d", os.Getpid())
root := filepath.Join(cgroupFSRoot, name)
if err := os.Mkdir(root, 0755); err != nil {
if os.IsExist(err) {
} else {
return "", err
}
}
ok, err = hasController(filepath.Join(root, "cgroup.controllers"), "memory")
if err != nil {
_ = os.Remove(root)
return "", err
}
if !ok {
_ = os.Remove(root)
return "", fmt.Errorf("memory controller not inherited into %s", root)
}
if err := os.WriteFile(filepath.Join(root, "cgroup.subtree_control"), []byte("+memory"), 0644); err != nil {
_ = os.Remove(root)
return "", fmt.Errorf("enable +memory in %s: %w", root, err)
}
return root, nil
}
func createScopeCgroup() (string, error) {
data, err := os.ReadFile("/proc/self/cgroup")
if err != nil {
return "", fmt.Errorf("read /proc/self/cgroup: %w", err)
}
var rel string
for line := range strings.SplitSeq(strings.TrimSpace(string(data)), "\n") {
if after, ok := strings.CutPrefix(line, "0::"); ok {
rel = after
break
}
}
if rel == "" {
return "", fmt.Errorf("cgroup v2 not found in /proc/self/cgroup (unified hierarchy required)")
}
ownCg := filepath.Join(cgroupFSRoot, rel)
ok, err := hasController(filepath.Join(ownCg, "cgroup.controllers"), "memory")
if err != nil {
return "", fmt.Errorf("cgroup %s not accessible: %w", ownCg, err)
}
if !ok {
return "", fmt.Errorf("memory controller not delegated to %s", ownCg)
}
initCg := filepath.Join(ownCg, "judge.init")
if err := os.MkdirAll(initCg, 0755); err != nil {
return "", fmt.Errorf("mkdir %s: %w", initCg, err)
}
if err := os.WriteFile(filepath.Join(initCg, "cgroup.procs"), []byte(strconv.Itoa(os.Getpid())), 0644); err != nil {
return "", fmt.Errorf("move judge into %s: %w", initCg, err)
}
if err := os.WriteFile(filepath.Join(ownCg, "cgroup.subtree_control"), []byte("+memory"), 0644); err != nil {
enabled, _ := hasController(filepath.Join(ownCg, "cgroup.subtree_control"), "memory")
if !enabled {
return "", fmt.Errorf("enable +memory in %s/cgroup.subtree_control: %w", ownCg, err)
}
}
return ownCg, nil
}
func cleanupCgroupRoot() {
if cgroupRoot == "" || !cgroupRootOwned {
return
}
_ = os.Remove(cgroupRoot)
cgroupRoot = ""
cgroupRootOwned = false
}
type linuxLimiter struct {
memLimit int64
cgPath string
}
func newLimiter(memLimit int64) limiter {
return &linuxLimiter{memLimit: memLimit}
}
func (l *linuxLimiter) prepare(cmd *exec.Cmd) error {
if l.memLimit <= 0 {
return nil
}
root, err := ensureCgroupRoot()
if err != nil {
return err
}
name := fmt.Sprintf("judge.test.%d.%d", os.Getpid(), atomic.AddInt64(&cgroupCounter, 1))
l.cgPath = filepath.Join(root, name)
if err := os.Mkdir(l.cgPath, 0755); err != nil {
l.cgPath = ""
return fmt.Errorf("mkdir %s: %w", name, err)
}
if err := os.WriteFile(filepath.Join(l.cgPath, "memory.max"), []byte(strconv.FormatInt(l.memLimit, 10)), 0644); err != nil {
_ = os.Remove(l.cgPath)
l.cgPath = ""
return fmt.Errorf("write memory.max: %w", err)
}
swapMax := filepath.Join(l.cgPath, "memory.swap.max")
if _, err := os.Stat(swapMax); err == nil {
_ = os.WriteFile(swapMax, []byte("0"), 0644)
}
return nil
}
func (l *linuxLimiter) afterStart(cmd *exec.Cmd) error {
if l.cgPath == "" || cmd.Process == nil {
return nil
}
return os.WriteFile(filepath.Join(l.cgPath, "cgroup.procs"), []byte(strconv.Itoa(cmd.Process.Pid)), 0644)
}
func (l *linuxLimiter) collect() limitStats {
if l.cgPath == "" {
return limitStats{}
}
var s limitStats
if data, err := os.ReadFile(filepath.Join(l.cgPath, "memory.peak")); err == nil {
if n, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64); err == nil {
s.PeakMemory = n
}
}
if data, err := os.ReadFile(filepath.Join(l.cgPath, "memory.events")); err == nil {
for line := range strings.SplitSeq(string(data), "\n") {
fields := strings.Fields(line)
if len(fields) != 2 {
continue
}
if (fields[0] == "oom_kill" || fields[0] == "oom_group_kill") && fields[1] != "0" {
s.MemoryExceeded = true
}
}
}
return s
}
func (l *linuxLimiter) cleanup() {
if l.cgPath == "" {
return
}
for range 10 {
err := os.Remove(l.cgPath)
if err == nil || os.IsNotExist(err) {
l.cgPath = ""
return
}
time.Sleep(20 * time.Millisecond)
}
}

29
runner/limiter_other.go Normal file
View File

@@ -0,0 +1,29 @@
//go:build !linux && !windows
package runner
import (
"fmt"
"os/exec"
)
type noopLimiter struct {
memLimit int64
}
func newLimiter(memLimit int64) limiter {
return &noopLimiter{memLimit: memLimit}
}
func (l *noopLimiter) prepare(cmd *exec.Cmd) error {
if l.memLimit > 0 {
return fmt.Errorf("memory_limit is not supported on this platform (only linux/windows)")
}
return nil
}
func (l *noopLimiter) afterStart(cmd *exec.Cmd) error { return nil }
func (l *noopLimiter) collect() limitStats { return limitStats{} }
func (l *noopLimiter) cleanup() {}
func cleanupCgroupRoot() {}

185
runner/limiter_windows.go Normal file
View File

@@ -0,0 +1,185 @@
//go:build windows
package runner
import (
"fmt"
"os/exec"
"unsafe"
"golang.org/x/sys/windows"
)
type windowsLimiter struct {
memLimit int64
job windows.Handle
iocp windows.Handle
peak int64
exceeded bool
}
func newLimiter(memLimit int64) limiter {
return &windowsLimiter{memLimit: memLimit}
}
const (
jobObjectExtendedLimitInformationClass = 9
jobObjectAssociateCompletionPortInformationClass = 7
jobObjectLimitProcessMemory = 0x00000100
jobObjectLimitKillOnJobClose = 0x00002000
jobObjectMsgProcessMemoryLimit = 9
jobObjectMsgJobMemoryLimit = 10
)
type ioCounters struct {
ReadOperationCount uint64
WriteOperationCount uint64
OtherOperationCount uint64
ReadTransferCount uint64
WriteTransferCount uint64
OtherTransferCount uint64
}
type jobObjectBasicLimitInformation struct {
PerProcessUserTimeLimit int64
PerJobUserTimeLimit int64
LimitFlags uint32
MinimumWorkingSetSize uintptr
MaximumWorkingSetSize uintptr
ActiveProcessLimit uint32
Affinity uintptr
PriorityClass uint32
SchedulingClass uint32
}
type jobObjectExtendedLimitInformation struct {
BasicLimitInformation jobObjectBasicLimitInformation
IoInfo ioCounters
ProcessMemoryLimit uintptr
JobMemoryLimit uintptr
PeakProcessMemoryUsed uintptr
PeakJobMemoryUsed uintptr
}
type jobObjectAssociateCompletionPort struct {
CompletionKey uintptr
CompletionPort windows.Handle
}
func (l *windowsLimiter) prepare(cmd *exec.Cmd) error {
if l.memLimit <= 0 {
return nil
}
job, err := windows.CreateJobObject(nil, nil)
if err != nil {
return fmt.Errorf("CreateJobObject: %w", err)
}
info := jobObjectExtendedLimitInformation{
BasicLimitInformation: jobObjectBasicLimitInformation{
LimitFlags: jobObjectLimitProcessMemory | jobObjectLimitKillOnJobClose,
},
ProcessMemoryLimit: uintptr(l.memLimit),
}
if _, err := windows.SetInformationJobObject(
job,
jobObjectExtendedLimitInformationClass,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
); err != nil {
windows.CloseHandle(job)
return fmt.Errorf("SetInformationJobObject(extended): %w", err)
}
iocp, err := windows.CreateIoCompletionPort(windows.InvalidHandle, 0, 0, 1)
if err != nil {
windows.CloseHandle(job)
return fmt.Errorf("CreateIoCompletionPort: %w", err)
}
assoc := jobObjectAssociateCompletionPort{
CompletionKey: uintptr(job),
CompletionPort: iocp,
}
if _, err := windows.SetInformationJobObject(
job,
jobObjectAssociateCompletionPortInformationClass,
uintptr(unsafe.Pointer(&assoc)),
uint32(unsafe.Sizeof(assoc)),
); err != nil {
windows.CloseHandle(iocp)
windows.CloseHandle(job)
return fmt.Errorf("SetInformationJobObject(iocp): %w", err)
}
l.job = job
l.iocp = iocp
return nil
}
func (l *windowsLimiter) afterStart(cmd *exec.Cmd) error {
if l.job == 0 || cmd.Process == nil {
return nil
}
procHandle, err := windows.OpenProcess(
windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE|windows.PROCESS_QUERY_INFORMATION,
false,
uint32(cmd.Process.Pid),
)
if err != nil {
return fmt.Errorf("OpenProcess: %w", err)
}
defer windows.CloseHandle(procHandle)
if err := windows.AssignProcessToJobObject(l.job, procHandle); err != nil {
return fmt.Errorf("AssignProcessToJobObject: %w", err)
}
return nil
}
func (l *windowsLimiter) collect() limitStats {
if l.job == 0 {
return limitStats{}
}
if l.iocp != 0 {
for {
var bytes uint32
var key uintptr
var overlapped *windows.Overlapped
err := windows.GetQueuedCompletionStatus(l.iocp, &bytes, &key, &overlapped, 0)
if err != nil {
break
}
if bytes == jobObjectMsgProcessMemoryLimit || bytes == jobObjectMsgJobMemoryLimit {
l.exceeded = true
}
}
}
var info jobObjectExtendedLimitInformation
var ret uint32
if err := windows.QueryInformationJobObject(
l.job,
jobObjectExtendedLimitInformationClass,
uintptr(unsafe.Pointer(&info)),
uint32(unsafe.Sizeof(info)),
&ret,
); err == nil {
l.peak = int64(info.PeakProcessMemoryUsed)
}
return limitStats{PeakMemory: l.peak, MemoryExceeded: l.exceeded}
}
func (l *windowsLimiter) cleanup() {
if l.iocp != 0 {
windows.CloseHandle(l.iocp)
l.iocp = 0
}
if l.job != 0 {
windows.CloseHandle(l.job)
l.job = 0
}
}
func cleanupCgroupRoot() {}

View File

@@ -2,6 +2,7 @@ package runner
import (
"fmt"
"math"
"time"
)
@@ -11,6 +12,7 @@ const (
StatusPass Status = iota
StatusFail
StatusTLE
StatusMLE
StatusBuildError
StatusRuntimeError
)
@@ -23,6 +25,8 @@ func (s Status) String() string {
return "FAIL"
case StatusTLE:
return "TLE"
case StatusMLE:
return "MLE"
case StatusBuildError:
return "BUILD_ERROR"
case StatusRuntimeError:
@@ -37,6 +41,9 @@ type TestResult struct {
Status Status
Elapsed time.Duration
PeakMemory int64
MemoryLimit int64
Failures []string
ActualStdout string
@@ -58,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
}

95
runner/result_test.go Normal file
View File

@@ -0,0 +1,95 @@
package runner
import (
"strings"
"testing"
)
func TestStatusString(t *testing.T) {
cases := []struct {
s Status
want string
}{
{StatusPass, "PASS"},
{StatusFail, "FAIL"},
{StatusTLE, "TLE"},
{StatusMLE, "MLE"},
{StatusBuildError, "BUILD_ERROR"},
{StatusRuntimeError, "RE"},
{Status(999), "UNKNOWN"},
}
for _, c := range cases {
if got := c.s.String(); got != c.want {
t.Errorf("Status(%d).String() = %q, want %q", c.s, got, c.want)
}
}
}
func TestAddFailureAppends(t *testing.T) {
r := &TestResult{}
r.addFailure("first %s", "msg")
r.addFailure("second %d", 2)
if len(r.Failures) != 2 {
t.Fatalf("Failures len = %d, want 2", len(r.Failures))
}
if r.Failures[0] != "first msg" {
t.Errorf("Failures[0] = %q", r.Failures[0])
}
if !strings.Contains(r.Failures[1], "second 2") {
t.Errorf("Failures[1] = %q", r.Failures[1])
}
}
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 {
@@ -59,43 +61,250 @@ func resolveBinary(workDir, name string) string {
}
func (r *Runner) Run() *SuiteResult {
defer cleanupCgroupRoot()
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 {
@@ -119,30 +328,35 @@ func (r *Runner) buildCommand() string {
return "go build -o solution ."
}
func (r *Runner) build() (string, error) {
buildCmd := r.buildCommand()
ctx := context.Background()
if r.file.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, r.file.Timeout)
defer cancel()
func (r *Runner) findSources() (string, error) {
if r.file.Sources == "" {
return "", nil
}
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())
var files []string
err := filepath.Walk(r.cfg.WorkDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if info.Name() == ".git" || info.Name() == ".gitea" {
return filepath.SkipDir
}
return nil
}
matched, _ := filepath.Match(r.file.Sources, info.Name())
if matched {
rel, _ := filepath.Rel(r.cfg.WorkDir, path)
files = append(files, filepath.ToSlash(rel))
}
return nil
})
if err != nil {
return "", fmt.Errorf("source discovery: %w", err)
}
return out.String(), nil
if len(files) == 0 {
return "", fmt.Errorf("no files matching %q found", r.file.Sources)
}
return strings.Join(files, " "), nil
}
func shellCommand(ctx context.Context, cmdline string) *exec.Cmd {
@@ -186,6 +400,9 @@ func (r *Runner) runGroup(g *dsl.Group) *GroupResult {
if t.Timeout == 0 {
t.Timeout = g.Timeout
}
if t.MemoryLimit == 0 {
t.MemoryLimit = g.MemoryLimit
}
if t.Wrapper == "" {
t.Wrapper = g.Wrapper
}
@@ -274,10 +491,34 @@ func (r *Runner) runTest(t *dsl.Test) *TestResult {
cmd.Stdout = stdout
cmd.Stderr = stderr
tr.MemoryLimit = t.MemoryLimit
lim := newLimiter(t.MemoryLimit)
if err := lim.prepare(cmd); err != nil {
tr.Status = StatusRuntimeError
tr.addFailure("memory limiter setup: %v", err)
return tr
}
defer lim.cleanup()
start := time.Now()
runErr := cmd.Run()
if err := cmd.Start(); err != nil {
tr.Status = StatusRuntimeError
tr.addFailure("start: %v", err)
return tr
}
if err := lim.afterStart(cmd); err != nil {
killProcessGroup(cmd)
_ = cmd.Wait()
tr.Status = StatusRuntimeError
tr.addFailure("memory limiter attach: %v", err)
return tr
}
runErr := cmd.Wait()
tr.Elapsed = time.Since(start)
stats := lim.collect()
tr.PeakMemory = stats.PeakMemory
if ctx.Err() == context.DeadlineExceeded {
killProcessGroup(cmd)
}
@@ -295,6 +536,12 @@ func (r *Runner) runTest(t *dsl.Test) *TestResult {
return tr
}
if stats.MemoryExceeded {
tr.Status = StatusMLE
tr.addFailure("memory limit exceeded (limit %d bytes, peak %d bytes)", t.MemoryLimit, stats.PeakMemory)
return tr
}
actualCode := 0
if runErr != nil {
if exitErr, ok := runErr.(*exec.ExitError); ok {
@@ -311,30 +558,32 @@ func (r *Runner) runTest(t *dsl.Test) *TestResult {
tr.addFailure("exit code: expected %d, got %d", *t.ExitCode, actualCode)
}
for _, f := range applyMatcher("stdout", t.Stdout, tr.ActualStdout) {
for _, f := range t.Stdout.Match("stdout", tr.ActualStdout) {
tr.addFailure("%s", f)
}
for _, f := range applyMatcher("stderr", t.Stderr, tr.ActualStderr) {
for _, f := range t.Stderr.Match("stderr", tr.ActualStderr) {
tr.addFailure("%s", f)
}
if len(tr.Failures) > 0 {
tr.Status = StatusFail
}
for name, expected := range t.OutFiles {
path := filepath.Join(tmpDir, name)
content, err := os.ReadFile(path)
actualPath := filepath.Join(tmpDir, name)
data, err := os.ReadFile(actualPath)
if err != nil {
tr.addFailure("output file %q not found: %v", name, err)
tr.Status = StatusFail
tr.addFailure("output file %q missing: %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)
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)
}
}
if tr.Status == StatusPass && len(tr.Failures) > 0 {
tr.Status = StatusFail
}
return tr
}