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:
2026-04-21 23:11:14 +09:00
parent 716fdfb6b2
commit 96ba9e3a56
11 changed files with 411 additions and 7 deletions
+3
View File
@@ -5,6 +5,9 @@ import PackageDescription
let package = Package(
name: "sonya",
platforms: [
.macOS(.v13)
],
dependencies: [
.package(path: "Sources/sonyalib"),
],
+43
View File
@@ -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`, ...)
+23 -3
View File
@@ -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)'")
}
}
}
+3
View File
@@ -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")
}