From 96ba9e3a5652591fa575c1c37ba1d125f93c726e Mon Sep 17 00:00:00 2001 From: hwachakarter Date: Tue, 21 Apr 2026 23:11:14 +0900 Subject: [PATCH] 0.1v: basic features added lexer, parser, Shell, Executor. added tests for sonyalib it already works! will be implementing more features soon. --- Package.swift | 3 + README.md | 43 +++++++ Sources/sonya/sonya.swift | 26 ++++- Sources/sonyalib/Package.swift | 3 + .../sonyalib/Sources/sonyalib/Executor.swift | 46 ++++++++ Sources/sonyalib/Sources/sonyalib/Lexer.swift | 81 +++++++++++++ .../sonyalib/Sources/sonyalib/Parcer.swift | 39 +++++++ Sources/sonyalib/Sources/sonyalib/Shell.swift | 39 +++++++ .../sonyalib/Sources/sonyalib/VarRegex.swift | 26 +++++ .../sonyalib/Sources/sonyalib/sonyalib.swift | 2 - .../Tests/sonyalibTests/sonyalibTests.swift | 110 +++++++++++++++++- 11 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 README.md create mode 100644 Sources/sonyalib/Sources/sonyalib/Executor.swift create mode 100644 Sources/sonyalib/Sources/sonyalib/Lexer.swift create mode 100644 Sources/sonyalib/Sources/sonyalib/Parcer.swift create mode 100644 Sources/sonyalib/Sources/sonyalib/Shell.swift create mode 100644 Sources/sonyalib/Sources/sonyalib/VarRegex.swift delete mode 100644 Sources/sonyalib/Sources/sonyalib/sonyalib.swift diff --git a/Package.swift b/Package.swift index 44285ab..070fe32 100644 --- a/Package.swift +++ b/Package.swift @@ -5,6 +5,9 @@ import PackageDescription let package = Package( name: "sonya", + platforms: [ + .macOS(.v13) + ], dependencies: [ .package(path: "Sources/sonyalib"), ], diff --git a/README.md b/README.md new file mode 100644 index 0000000..8559155 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# sonya + +A lightweight `just`-like task runner written in Swift. + +Define recipes in a `sonyafile` and run them with `sonya `. + +## sonyafile syntax + +``` +CC = clang +FLAGS = -O2 + +build: + $(CC) $(FLAGS) main.c -o main + +run: build + ./main + +clean: + rm -f main +``` + +- Variables are defined as `key = value` and referenced as `$(key)` +- Recipes can declare dependencies after the colon - they run first +- Commands are indented with whitespace + +## Install + +```sh +swift build -c release +cp .build/release/sonya /usr/local/bin/sonya +``` + +## Usage + +```sh +sonya build # run a specific recipe +sonya # runs "default" recipe +``` + +## Roadmap + +- Command-line argument passing to recipes (`$1`, `$2`, ...) diff --git a/Sources/sonya/sonya.swift b/Sources/sonya/sonya.swift index 6727fe1..f205ef5 100644 --- a/Sources/sonya/sonya.swift +++ b/Sources/sonya/sonya.swift @@ -1,9 +1,29 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book +import sonyalib @main struct sonya { static func main() { - print("Hello, world!") + let args = CommandLine.arguments.dropFirst() + let recipe = args.first ?? "default" + + let result = runRecipe(recipe) + switch result { + case .OK: + break + case .noSonyafile: + print("sonya: no sonyafile found in current directory") + case .isEmpty: + print("sonya: sonyafile is empty") + case .syntaxError: + print("sonya: syntax error in sonyafile") + case .noSuchRecipe: + print("sonya: no recipe named '\(recipe)'") + case .noSuchDependency: + print("sonya: unknown dependency") + case .shellReturnedError: + print("sonya: command failed") + case .noSuchVariable: + print("sonya: undefined variable referenced in recipe '\(recipe)'") + } } } diff --git a/Sources/sonyalib/Package.swift b/Sources/sonyalib/Package.swift index e4be196..7562575 100644 --- a/Sources/sonyalib/Package.swift +++ b/Sources/sonyalib/Package.swift @@ -5,6 +5,9 @@ import PackageDescription let package = Package( name: "sonyalib", + platforms: [ + .macOS(.v13) + ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( diff --git a/Sources/sonyalib/Sources/sonyalib/Executor.swift b/Sources/sonyalib/Sources/sonyalib/Executor.swift new file mode 100644 index 0000000..4afb0a8 --- /dev/null +++ b/Sources/sonyalib/Sources/sonyalib/Executor.swift @@ -0,0 +1,46 @@ +// +// Executor.swift +// sonyalib +// +// Created by hwacha on 4/21/26. +// + +import Foundation + +public enum runResult { + case OK + case noSonyafile + case isEmpty + case syntaxError + case noSuchRecipe + case noSuchDependency + case shellReturnedError + case noSuchVariable +} + +public func runRecipe(_ recipeName: String, args: [String] = []) -> runResult { + guard let sonyafile = openSonyafile() else { return .noSonyafile } + + let tokenized = Lexer().tokenize(sonyafile) + guard !tokenized.isEmpty else { return .isEmpty } + + guard let ast = Parcer().parce(tokenized) else { return .syntaxError } + + guard let requestedRecipe = ast.rules[recipeName] else { return .noSuchRecipe } + // check for dependencies + if !requestedRecipe.dependencies.isEmpty { + for dependency in requestedRecipe.dependencies { + let result = runRecipe(dependency) + guard result == .OK else { return result } + } + } + + for command in requestedRecipe.commands { + guard let resolved = resolve(command, vars: ast.vars) else { return .noSuchVariable } + + guard shell(resolved) == 0 else { return .shellReturnedError } + } + + // all good + return .OK +} diff --git a/Sources/sonyalib/Sources/sonyalib/Lexer.swift b/Sources/sonyalib/Sources/sonyalib/Lexer.swift new file mode 100644 index 0000000..489ded2 --- /dev/null +++ b/Sources/sonyalib/Sources/sonyalib/Lexer.swift @@ -0,0 +1,81 @@ +import RegexBuilder + +enum Token : Equatable { + case recipe(String) + case dependency(String) + case command(String) + case variable(name: String, value: String) + case comment(String) + case newline +} + +struct Lexer { + + private nonisolated(unsafe) static let recipeRegex = Regex { + Anchor.startOfLine + Capture(OneOrMore { CharacterClass(.word, .anyOf("-./")) }) + ZeroOrMore(.whitespace) + ":" + Optionally { + ZeroOrMore(.whitespace) + Capture(ZeroOrMore(.anyNonNewline)) // dependencies + } + } + + private nonisolated(unsafe) static let commandRegex = Regex { + Anchor.startOfLine + OneOrMore(.horizontalWhitespace) + Capture(OneOrMore(.anyNonNewline)) + } + + private nonisolated(unsafe) static let variableRegex = Regex { + Anchor.startOfLine + Capture(OneOrMore(.word)) + ZeroOrMore(.whitespace) + "=" + ZeroOrMore(.whitespace) + Capture(ZeroOrMore(.anyNonNewline)) + } + + private nonisolated(unsafe) static let commentRegex = Regex { + Anchor.startOfLine + ZeroOrMore(.whitespace) + "#" + Capture(ZeroOrMore(.anyNonNewline)) + } + + func tokenize(_ input: String) -> [Token] { + input.split(omittingEmptySubsequences: false, whereSeparator: \.isNewline) + .flatMap { tokenizeLine(String($0)) } + } + + private func tokenizeLine(_ line: String) -> [Token] { + if line.isEmpty { + return [.newline] + } + + if let match = line.wholeMatch(of: Self.recipeRegex) { + let recipe = Token.recipe(String(match.1)) + let deps: [Token] = match.2 + .map(String.init)? + .split(separator: " ") + .map { .dependency(String($0)) } ?? [] + return [recipe] + deps + } + + if let match = line.wholeMatch(of: Self.commentRegex) { + return [.comment(String(match.1))] + } + + if let match = line.wholeMatch(of: Self.variableRegex) { + return [.variable(name: String(match.1), value: String(match.2))] + } + + if let match = line.wholeMatch(of: Self.commandRegex) { + return [.command(String(match.1))] + } + + return [] // unrecognized + } + +} diff --git a/Sources/sonyalib/Sources/sonyalib/Parcer.swift b/Sources/sonyalib/Sources/sonyalib/Parcer.swift new file mode 100644 index 0000000..19752bc --- /dev/null +++ b/Sources/sonyalib/Sources/sonyalib/Parcer.swift @@ -0,0 +1,39 @@ +struct sonyaAST { + var vars: [String : String] = [:] + var rules: [String : Rule] = [:] +} + +struct Rule { + var dependencies: [String] = [] + var commands: [String] = [] +} + +struct Parcer { + func parce(_ tokens: [Token]) -> sonyaAST? { + var ast = sonyaAST() + var currentRule: (name: String, rule: Rule)? = nil + + for token in tokens { + switch token { + case .variable(let name, let value): + if let rule = currentRule { ast.rules[rule.name] = rule.rule } + currentRule = nil + ast.vars[name] = value + case .recipe(let name): + if let rule = currentRule { ast.rules[rule.name] = rule.rule } + currentRule = (name: name, rule: Rule()) + case .dependency(let name): + currentRule?.rule.dependencies.append(name) + case .command(let cmd): + currentRule?.rule.commands.append(cmd) + case .newline, .comment: + continue + } + } + + if let rule = currentRule { ast.rules[rule.name] = rule.rule } + currentRule = nil + return ast + + } +} diff --git a/Sources/sonyalib/Sources/sonyalib/Shell.swift b/Sources/sonyalib/Sources/sonyalib/Shell.swift new file mode 100644 index 0000000..18ed201 --- /dev/null +++ b/Sources/sonyalib/Sources/sonyalib/Shell.swift @@ -0,0 +1,39 @@ +// +// Shell.swift +// sonyalib +// +// Created by hwacha on 4/21/26. +// + +import Foundation + +func openSonyafile() -> String? { + let fileManager = FileManager.default + let currentPath = fileManager.currentDirectoryPath + let filePath = (currentPath as NSString).appendingPathComponent("sonyafile") + + guard fileManager.fileExists(atPath: filePath) else { + return nil + } + + return try? String(contentsOfFile: filePath, encoding: .utf8) +} + +@discardableResult +func shell(_ command: String) -> Int32 { + let process = Process() + let pipe = Pipe() + + process.standardOutput = pipe + process.standardError = pipe + process.executableURL = URL(fileURLWithPath: "/bin/bash") + process.arguments = ["-c", command] + + try? process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + FileHandle.standardOutput.write(data) + + return process.terminationStatus +} diff --git a/Sources/sonyalib/Sources/sonyalib/VarRegex.swift b/Sources/sonyalib/Sources/sonyalib/VarRegex.swift new file mode 100644 index 0000000..284665c --- /dev/null +++ b/Sources/sonyalib/Sources/sonyalib/VarRegex.swift @@ -0,0 +1,26 @@ +// +// VarRegex.swift +// sonyalib +// +// Created by hwacha on 4/21/26. +// + +// TODO: args + +nonisolated(unsafe) let varRegex = #/\$\(([^\s\n]*)\)/# + +nonisolated(unsafe) let argRegex = #/\$(\d+)/# + +func resolve(_ command: String, vars: [String: String]) -> String? { + return resolveVars(command, vars: vars) +} + +private func resolveVars(_ command: String, vars: [String: String]) -> String? { + for match in command.matches(of: varRegex) { + let key = String(match.1) + guard vars[key] != nil else { return nil } + } + return command.replacing(varRegex) { match in + vars[String(match.1)] ?? "" + } +} diff --git a/Sources/sonyalib/Sources/sonyalib/sonyalib.swift b/Sources/sonyalib/Sources/sonyalib/sonyalib.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/sonyalib/Sources/sonyalib/sonyalib.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Sources/sonyalib/Tests/sonyalibTests/sonyalibTests.swift b/Sources/sonyalib/Tests/sonyalibTests/sonyalibTests.swift index 9bcae9b..7e3cb31 100644 --- a/Sources/sonyalib/Tests/sonyalibTests/sonyalibTests.swift +++ b/Sources/sonyalib/Tests/sonyalibTests/sonyalibTests.swift @@ -1,6 +1,112 @@ import Testing @testable import sonyalib -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. +// MARK: - Lexer + +@Test func lexer_tokenizes_recipe_with_no_deps() { + let tokens = Lexer().tokenize("build:") + #expect(tokens == [.recipe("build")]) +} + +@Test func lexer_tokenizes_recipe_with_deps() { + let tokens = Lexer().tokenize("test: build lint") + #expect(tokens == [.recipe("test"), .dependency("build"), .dependency("lint")]) +} + +@Test func lexer_tokenizes_command() { + let tokens = Lexer().tokenize(" echo hello") + #expect(tokens == [.command("echo hello")]) +} + +@Test func lexer_tokenizes_variable() { + let tokens = Lexer().tokenize("CC = clang") + #expect(tokens == [.variable(name: "CC", value: "clang")]) +} + +@Test func lexer_tokenizes_comment() { + let tokens = Lexer().tokenize("# this is a comment") + #expect(tokens == [.comment(" this is a comment")]) +} + +@Test func lexer_empty_line_produces_newline() { + let tokens = Lexer().tokenize("") + #expect(tokens == [.newline]) +} + +@Test func lexer_multiline_sonyafile() { + let input = """ + build: + swift build + """ + let tokens = Lexer().tokenize(input) + #expect(tokens == [.recipe("build"), .command("swift build")]) +} + +// MARK: - Parser + +@Test func parser_single_recipe_with_command() { + let tokens: [Token] = [.recipe("build"), .command("swift build")] + let ast = Parcer().parce(tokens) + #expect(ast != nil) + #expect(ast?.rules["build"]?.commands == ["swift build"]) +} + +@Test func parser_recipe_with_dependency() { + let tokens: [Token] = [ + .recipe("test"), .dependency("build"), + .command("swift test") + ] + let ast = Parcer().parce(tokens) + #expect(ast?.rules["test"]?.dependencies == ["build"]) + #expect(ast?.rules["test"]?.commands == ["swift test"]) +} + +@Test func parser_variable_is_stored() { + let tokens: [Token] = [.variable(name: "CC", value: "clang")] + let ast = Parcer().parce(tokens) + #expect(ast?.vars["CC"] == "clang") +} + +@Test func parser_multiple_recipes() { + let tokens: [Token] = [ + .recipe("build"), .command("swift build"), + .newline, + .recipe("test"), .command("swift test"), + ] + let ast = Parcer().parce(tokens) + #expect(ast?.rules["build"]?.commands == ["swift build"]) + #expect(ast?.rules["test"]?.commands == ["swift test"]) +} + +@Test func parser_variable_before_recipe_flushes_context() { + // A variable declaration after a recipe must flush the current recipe first. + let tokens: [Token] = [ + .recipe("build"), .command("swift build"), + .variable(name: "X", value: "1"), + ] + let ast = Parcer().parce(tokens) + #expect(ast?.rules["build"]?.commands == ["swift build"]) + #expect(ast?.vars["X"] == "1") +} + +// MARK: - Variable resolution + +@Test func resolve_substitutes_known_variable() { + let result = resolve("$(CC) main.c", vars: ["CC": "clang"]) + #expect(result == "clang main.c") +} + +@Test func resolve_returns_nil_for_unknown_variable() { + let result = resolve("$(MISSING) main.c", vars: [:]) + #expect(result == nil) +} + +@Test func resolve_no_variables_passes_through() { + let result = resolve("echo hello", vars: [:]) + #expect(result == "echo hello") +} + +@Test func resolve_multiple_substitutions() { + let result = resolve("$(CC) $(FLAGS) main.c", vars: ["CC": "clang", "FLAGS": "-O2"]) + #expect(result == "clang -O2 main.c") }