0.1v: basic features
added lexer, parser, Shell, Executor. added tests for sonyalib it already works! will be implementing more features soon.
This commit is contained in:
@@ -5,6 +5,9 @@ import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "sonya",
|
||||
platforms: [
|
||||
.macOS(.v13)
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "Sources/sonyalib"),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# sonya
|
||||
|
||||
A lightweight `just`-like task runner written in Swift.
|
||||
|
||||
Define recipes in a `sonyafile` and run them with `sonya <recipe>`.
|
||||
|
||||
## 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`, ...)
|
||||
@@ -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)'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)] ?? ""
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user