feat: pattern support args and multiple variants; add zed extension for highlight
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 8s
build-dsl-smoke / release / clang / linux (push) Successful in 9s
build-dsl-smoke / release / gcc / linux (push) Successful in 7s
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 16s
build-dsl-smoke / debug-valgrind / gcc / linux (push) Successful in 14s
build-dsl-smoke / debug / msvc / windows (push) Successful in 18s
build-dsl-smoke / release / clang / windows (push) Successful in 17s
build-dsl-smoke / release / msvc / windows (push) Successful in 17s
build-dsl-smoke / SUMMARY (push) Successful in 5s

This commit is contained in:
2026-04-11 14:37:43 +03:00
parent dacae83dc6
commit 7f9f6a0a6e
29 changed files with 11429 additions and 94 deletions

View File

@@ -50,6 +50,8 @@ type Pattern struct {
DirsGlob string
InputFile string
OutputFile string
Args []string
}
func (p *Pattern) IsDirMode() bool {

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())
}
}

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
}

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{}
@@ -107,7 +169,10 @@ func (p *Parser) parseFile() (*File, error) {
if err != nil {
return nil, err
}
f.BuildDefaults = bc
if f.BuildDefaults == nil {
f.BuildDefaults = &BuildConfig{}
}
f.BuildDefaults.MergeFrom(bc)
case "toolchains":
p.advance()
@@ -115,7 +180,45 @@ func (p *Parser) parseFile() (*File, error) {
if err != nil {
return nil, err
}
f.Toolchains = specs
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()
@@ -216,13 +319,6 @@ func (p *Parser) parseFile() (*File, error) {
}
}
if err := p.validateWeights(f); err != nil {
return nil, err
}
if err := validateBuilds(f); err != nil {
return nil, err
}
return f, nil
}
@@ -683,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)
}