diff --git a/README.md b/README.md index a0973fc..ce98156 100644 --- a/README.md +++ b/README.md @@ -1 +1,91 @@ -# makego-rawan \ No newline at end of file +# Makego + +This repository provides a Go-based tool to execute Makefile targets with support for concurrency. + +## Features + +- Execute Makefile targets directly from Go. +- Concurrency support: independent stages can run in parallel. +- Ordered execution for dependent stages. +- Error handling through channels for safe concurrent execution. + + +## Installation + +1. Clone this repository: + + ``` bash + git clone https://github.com/codescalersinternships/makego-rawan.git + cd makego-rawan + ``` + +## Usage + +### Code Example + +```go +import makego "github.com/codescalersinternships/makego-rawan/pkg" + +func main() { + err := makego.ExecuteMakefile(path, targets) + if err != nil { + panic(err) + } +} +``` + +### Running a Makefile Target + +``` bash +go run cmd/main.go -f "testdata/makefile" target +``` + +### Example + +Given the following Makefile: + +``` makefile +hello: hello1.txt hello2.txt + cat hello1.txt + cat hello2.txt + +hello1.txt: + echo "Hello 1, Make!" > hello1.txt + +hello2.txt: + echo "Hello 2, Make!" > hello2.txt + +clean: + rm -f hello1.txt + rm -f hello2.txt + +``` + +Running: + +``` bash +go run cmd/main.go -f "testdata/makefile" hello +``` + +Will produce: + + echo "Hello 2, Make!" > hello2.txt + echo "Hello 1, Make!" > hello1.txt + cat hello1.txt + Hello 1, Make! + cat hello2.txt + Hello 2, Make! + +### Flags + +- `-f`: The makefile to be executed (must be provided) + +### Arguments + +- `` : The target to be run (optional) + - default: first target in the makefile + +## Concurrency + +- Independent targets run in parallel +- Channels are used to handle errors \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..27fc087 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "flag" + "fmt" + "strings" + + makego "github.com/codescalersinternships/makego-rawan/pkg" +) + +func PrintRules(rules []makego.Stage) { + for _, r := range rules { + if len(r.Dependencies) > 0 { + fmt.Printf("%s: %s\n", r.Target, strings.Join(r.Dependencies, " ")) + } else { + fmt.Printf("%s:\n", r.Target) + } + + for _, cmd := range r.Commands { + fmt.Printf("\t%s\n", cmd) + } + + fmt.Println() + } +} +func main() { + file := flag.String("f", "Makefile", "Path to the makefile") + flag.Parse() + + args := flag.Args() + + var targets []string + targets = nil + + if len(args) > 0 { + targets = args[0:] + } + + err := makego.ExecuteMakefile(*file, targets) + if err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2a18726 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/codescalersinternships/makego-rawan + +go 1.25.0 diff --git a/pkg/depsHandler.go b/pkg/depsHandler.go new file mode 100644 index 0000000..c93a9d4 --- /dev/null +++ b/pkg/depsHandler.go @@ -0,0 +1,49 @@ +package makego + +import "slices" + +func buildGraph(stages []Stage) map[string][]string { + graph := make(map[string][]string) + for _, s := range stages { + graph[s.Target] = s.Dependencies + } + return graph +} + +func dfs(node string, graph map[string][]string, visited map[string]bool, stack []string, orderedTargets *[]string) error { + if visited[node] { + return nil + } + if slices.Contains(stack, node) { + return ErrCircularDependency + } + + stack = append(stack, node) + + for _, dep := range graph[node] { + err := dfs(dep, graph, visited, stack, orderedTargets) + if err != nil { + return err + } + } + visited[node] = true + *orderedTargets = append(*orderedTargets, node) + return nil +} + +func dependencyResolver(stages []Stage, root string) ([]string, error) { + graph := buildGraph(stages) + visited := make(map[string]bool) + stack := make([]string, 0) + orderedTargets := make([]string, 0) + + for target := range graph { + if !visited[target] { + err := dfs(root, graph, visited, stack, &orderedTargets) + if err != nil { + return nil, err + } + } + } + return orderedTargets, nil +} diff --git a/pkg/depsHandler_test.go b/pkg/depsHandler_test.go new file mode 100644 index 0000000..a93a660 --- /dev/null +++ b/pkg/depsHandler_test.go @@ -0,0 +1,58 @@ +package makego + +import ( + "reflect" + "testing" +) + +func TestDependencyResolver(t *testing.T) { + testcases := []struct { + name string + stages []Stage + expectError error + expected []string + }{ + { + name: "Simple linear dependencies", + stages: []Stage{ + {Target: "A", Dependencies: []string{"B"}, Commands: []string{"echo A"}}, + {Target: "B", Dependencies: []string{"C"}, Commands: []string{"echo B"}}, + {Target: "C", Dependencies: []string{}, Commands: []string{"echo C"}}, + }, + expectError: nil, + expected: []string{"C", "B", "A"}, + }, + { + name: "Circular dependency", + stages: []Stage{ + {Target: "A", Dependencies: []string{"B"}, Commands: []string{"echo A"}}, + {Target: "B", Dependencies: []string{"A"}, Commands: []string{"echo B"}}, + }, + expectError: ErrCircularDependency, + expected: nil, + }, + { + name: "Indirect Circular dependency", + stages: []Stage{ + {Target: "A", Dependencies: []string{"B"}, Commands: []string{"echo A"}}, + {Target: "B", Dependencies: []string{"C"}, Commands: []string{"echo C"}}, + {Target: "C", Dependencies: []string{"D"}, Commands: []string{"echo D"}}, + {Target: "D", Dependencies: []string{"A"}, Commands: []string{"echo A"}}, + }, + expectError: ErrCircularDependency, + expected: nil, + }, + } + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + ordered, err := dependencyResolver(tc.stages, tc.stages[0].Target) + if err != tc.expectError { + t.Errorf("expected error: %v, got: %v", tc.expectError, err) + } + + if !reflect.DeepEqual(ordered, tc.expected) { + t.Fatalf("expected order: %v, got: %v", tc.expected, ordered) + } + }) + } +} diff --git a/pkg/executer.go b/pkg/executer.go new file mode 100644 index 0000000..21c3aee --- /dev/null +++ b/pkg/executer.go @@ -0,0 +1,97 @@ +package makego + +import ( + "fmt" + "os" + "os/exec" + "sync" +) + +func getStageByTarget(stages []Stage, target string) *Stage { + for i, s := range stages { + if s.Target == target { + return &stages[i] + } + } + return nil +} + +func ExecuteMakefile(path string, targets []string) error { + makefile, err := parseMakefile(path) + if err != nil { + return err + } + defaultTarget := makefile.Stages[0].Target + + if len(targets) == 0 { + targets = []string{defaultTarget} + } + + for _, target := range targets { + ordered, err := dependencyResolver(makefile.Stages, target) // get the ordered list of targets to execute + if err != nil { + return err + } + + done := make(map[string]bool) + + for i := 0; i < len(ordered); { + var wg sync.WaitGroup // wait group to wait for all goroutines to finish + ch := make(chan error, len(ordered)) // channel to collect errors from goroutines + level := []string{} + for j := i; j < len(ordered); j++ { + stage := getStageByTarget(makefile.Stages, ordered[j]) + if stage == nil { + continue + } + ready := true + for _, dep := range stage.Dependencies { // check if all dependencies of the current target are done + if !done[dep] { + ready = false + break + } + } + + if ready { + level = append(level, ordered[j]) + } + + } + for _, t := range level { + wg.Add(1) // increment wait group counter (means wait for one more goroutine) + go func(target string) { + defer wg.Done() + stage := getStageByTarget(makefile.Stages, target) + err := runStage(stage) + if err != nil { + ch <- err + return + } + done[target] = true + + }(t) + } + wg.Wait() + close(ch) + if len(ch) > 0 { + return <-ch + } + i += len(level) + } + } + return nil +} + +func runStage(stage *Stage) error { + for _, cmd := range stage.Commands { + fmt.Print(cmd + "\n") + cmd := exec.Command("sh", "-c", cmd) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + err := cmd.Run() + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/executer_test.go b/pkg/executer_test.go new file mode 100644 index 0000000..c5ec480 --- /dev/null +++ b/pkg/executer_test.go @@ -0,0 +1,97 @@ +package makego + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeMakefile(t *testing.T, content string) string { + t.Helper() + + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "Makefile") + + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp Makefile: %v", err) + } + + return path +} + +func TestExecuteMakefileIntegration(t *testing.T) { + t.Run("default target with dependencies only", func(t *testing.T) { + makefileContent := ` +hello: hello1 hello2 + cat hello1.txt + cat hello2.txt + +hello1: + echo "Hello 1, Make!" > hello1.txt + +hello2: + echo "Hello 2, Make!" > hello2.txt + +clean: + rm -f hello1.txt hello2.txt +` + path := writeMakefile(t, makefileContent) + + err := ExecuteMakefile(path, []string{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile("hello1.txt") + if err != nil { + t.Fatalf("expected hello1.txt to exist %v", err) + } + if !strings.Contains(string(data), "Hello 1, Make!") { + t.Errorf("unexpected hello1.txt content, got %q", string(data)) + } + + data, err = os.ReadFile("hello2.txt") + if err != nil { + t.Fatalf("expected hello2.txt to exist %v", err) + } + if !strings.Contains(string(data), "Hello 2, Make!") { + t.Errorf("unexpected hello2.txt content, got %q", string(data)) + } + }) + + t.Run("run target clean", func(t *testing.T) { + makefileContent := ` +clean: + rm -f dummy.txt +` + path := writeMakefile(t, makefileContent) + + if err := os.WriteFile("dummy.txt", []byte{}, 0644); err != nil { + t.Fatalf("failed to write dummy file: %v", err) + } + + err := ExecuteMakefile(path, []string{"clean"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + _, err = os.Stat("dummy.txt") + if !os.IsNotExist(err) { + t.Errorf("clean target didn't remove dummy file") + } + }) + + t.Run("fails on invalid command", func(t *testing.T) { + makefileContent := ` +invalid: + invalidcommand +` + path := writeMakefile(t, makefileContent) + + err := ExecuteMakefile(path, []string{"invalid"}) + if err == nil { + t.Fatalf("expected error for invalid command, got nil") + } + }) +} diff --git a/pkg/parser.go b/pkg/parser.go new file mode 100644 index 0000000..3de8084 --- /dev/null +++ b/pkg/parser.go @@ -0,0 +1,66 @@ +package makego + +import ( + "errors" + "os" + "strings" +) + +var ErrCircularDependency = errors.New("circular dependency detected") +var ErrInvalidMakefile = errors.New("invalid makefile format") + +type Stage struct { + Target string + Dependencies []string + Commands []string +} + +type Makefile struct { + Stages []Stage +} + +func readMakefile(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", err + } + + return string(data), nil +} + +func parseMakefile(path string) (*Makefile, error) { + content, err := readMakefile(path) + if err != nil { + return nil, err + } + lines := strings.Split(content, "\n") + var stages []Stage + for _, line := range lines { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.Contains(line, ":") { // target line + parts := strings.Split(line, ":") + if len(parts) != 2 { + return nil, ErrInvalidMakefile + } + + target := strings.TrimSpace(parts[0]) + dependencies_str := strings.TrimSpace(parts[1]) + dependencies := strings.Fields(dependencies_str) + stages = append(stages, Stage{Target: target, Dependencies: dependencies}) + + } else if strings.HasPrefix(line, "\t") { // command line + if len(stages) == 0 { + return nil, ErrInvalidMakefile + } + command := strings.TrimSpace(line) + currStage := &stages[len(stages)-1] + currStage.Commands = append(currStage.Commands, command) + } else { + return nil, ErrInvalidMakefile + } + } + return &Makefile{Stages: stages}, nil +} diff --git a/pkg/parser_test.go b/pkg/parser_test.go new file mode 100644 index 0000000..b6c8ef7 --- /dev/null +++ b/pkg/parser_test.go @@ -0,0 +1,56 @@ +package makego + +import ( + "reflect" + "testing" +) + +func TestParseMakefile(t *testing.T) { + testcases := []struct { + name string + path string + expectError error + expectedFile *Makefile + }{ + { + name: "Invalid Makefile format, more than one colon in target line", + path: "../testdata/makefile_invalid1", + expectError: ErrInvalidMakefile, + expectedFile: nil, + }, + { + name: "Invalid Makefile format, command without target", + path: "../testdata/makefile_invalid2", + expectError: ErrInvalidMakefile, + expectedFile: nil, + }, + { + name: "Valid Makefile", + path: "../testdata/makefile", + expectError: nil, + expectedFile: &Makefile{ + Stages: []Stage{ + {Target: "hello", Dependencies: []string{"hello1.txt", "hello2.txt"}, Commands: []string{"cat hello1.txt", "cat hello2.txt"}}, + {Target: "hello1.txt", Dependencies: []string{}, Commands: []string{`echo "Hello 1, Make!" > hello1.txt`}}, + {Target: "hello2.txt", Dependencies: []string{}, Commands: []string{`echo "Hello 2, Make!" > hello2.txt`}}, + {Target: "clean", Dependencies: []string{}, Commands: []string{"rm -f hello1.txt", "rm -f hello2.txt"}}, + }, + }}, + } + for _, tc := range testcases { + t.Run(tc.path, func(t *testing.T) { + makefile, err := parseMakefile(tc.path) + if err != tc.expectError { + t.Errorf("expected makefile: %v, got: %v", tc.expectError, err) + } + + if makefile == nil && tc.expectedFile == nil { + return + } + + if !reflect.DeepEqual(makefile, tc.expectedFile) { + t.Fatalf("expected makefile: %+v, got: %+v", tc.expectedFile, makefile) + } + }) + } +} diff --git a/testdata/makefile b/testdata/makefile new file mode 100644 index 0000000..51cd683 --- /dev/null +++ b/testdata/makefile @@ -0,0 +1,13 @@ +hello: hello1.txt hello2.txt + cat hello1.txt + cat hello2.txt +#comment line +hello1.txt: + echo "Hello 1, Make!" > hello1.txt + +hello2.txt: + echo "Hello 2, Make!" > hello2.txt + +clean: + rm -f hello1.txt + rm -f hello2.txt diff --git a/testdata/makefile_invalid1 b/testdata/makefile_invalid1 new file mode 100644 index 0000000..b92fd35 --- /dev/null +++ b/testdata/makefile_invalid1 @@ -0,0 +1,4 @@ +hello: hello1.txt : hello2.txt + cat hello1.txt + cat hello2.txt + \ No newline at end of file diff --git a/testdata/makefile_invalid2 b/testdata/makefile_invalid2 new file mode 100644 index 0000000..7d7b896 --- /dev/null +++ b/testdata/makefile_invalid2 @@ -0,0 +1,9 @@ +hello: hello1.txt hello2.txt + cat hello1.txt + cat hello2.txt + +hello1.txt: + echo "Hello 1, Make!" > hello1.txt + +echo "Hello 2, Make!" > hello2.txt +