Skip to content

Commit 8716a7b

Browse files
committed
3/3 Implement nice API on top of AeroShellParser generated code
#278
1 parent a4e8942 commit 8716a7b

File tree

3 files changed

+334
-0
lines changed

3 files changed

+334
-0
lines changed
+228
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import AeroShellParserGenerated
2+
import Antlr4
3+
import Common
4+
5+
/// Use the following technique for quick grammar testing:
6+
/// source .deps/python-venv/bin/activate.fish
7+
/// echo "foo bar" | antlr4-parse ./grammar/AeroShellLexer.g4 ./grammar/AeroShellParser.g4 root -gui
8+
extension String {
9+
func parseShell() -> Result<RawShell, String> {
10+
let stream = ANTLRInputStream(self)
11+
let lexer = AeroShellLexer(stream)
12+
let errorsCollector = ErrorListenerCollector()
13+
lexer.addErrorListener(errorsCollector)
14+
let tokenStream = CommonTokenStream(lexer)
15+
let parser: AeroShellParser
16+
switch Result(catching: { try AeroShellParser(tokenStream) }) {
17+
case .success(let x): parser = x
18+
case .failure(let msg):
19+
return .failure(msg.localizedDescription)
20+
}
21+
parser.addErrorListener(errorsCollector)
22+
let root: AeroShellParser.RootContext
23+
switch Result(catching: { try parser.root() }) {
24+
case .success(let x): root = x
25+
case .failure(let msg):
26+
return .failure(msg.localizedDescription)
27+
}
28+
if !errorsCollector.errors.isEmpty {
29+
return .failure(errorsCollector.errors.joinErrors())
30+
}
31+
return root.program().map { $0.toTyped() } ?? .success(.empty)
32+
}
33+
}
34+
35+
class ErrorListenerCollector: BaseErrorListener {
36+
var errors: [String] = []
37+
override func syntaxError<T>(
38+
_ recognizer: Recognizer<T>,
39+
_ offendingSymbol: AnyObject?,
40+
_ line: Int,
41+
_ charPositionInLine: Int,
42+
_ msg: String,
43+
_ e: AnyObject?
44+
) {
45+
errors.append("Syntax error at \(line):\(charPositionInLine) \(msg)")
46+
}
47+
}
48+
49+
extension AeroShellParser.ProgramContext {
50+
func toTyped() -> Result<RawShell, String> {
51+
if let x = self as? AeroShellParser.NotContext {
52+
return x.program().toTyped("not node: nil child")
53+
}
54+
if let x = self as? AeroShellParser.PipeContext {
55+
return binaryNode(Shell.pipe, x.program(0), x.program(1))
56+
}
57+
if let x = self as? AeroShellParser.AndContext {
58+
return binaryNode(Shell.and, x.program(0), x.program(1))
59+
}
60+
if let x = self as? AeroShellParser.OrContext {
61+
return binaryNode(Shell.or, x.program(0), x.program(1))
62+
}
63+
if let x = self as? AeroShellParser.SeqContext {
64+
let seq = x.program()
65+
return switch seq.count {
66+
case 0: .failure("seq node: 0 children")
67+
case 1: seq.first!.toTyped()
68+
default: seq.mapAllOrFailures { $0.toTyped() }.mapError { $0.joinErrors() }.map(Shell.seq)
69+
}
70+
}
71+
if let x = self as? AeroShellParser.ParensContext {
72+
return x.program().toTyped("parens node: nil childe")
73+
}
74+
if let x = self as? AeroShellParser.ArgsContext {
75+
return x.arg().mapAllOrFailures { $0.toTyped() }.mapError { $0.joinErrors() }.map(Shell.args)
76+
}
77+
error("Unknown node type: \(self)")
78+
}
79+
}
80+
81+
82+
extension AeroShellParser.ArgContext {
83+
func toTyped() -> Result<ShellString<String>, String> {
84+
if let x = self as? AeroShellParser.WordContext {
85+
return .success(.text(x.getText()))
86+
}
87+
if let x = self as? AeroShellParser.DQuotedStringContext {
88+
let seq = x.dStringFragment()
89+
return switch seq.count {
90+
case 1: seq.first!.toTyped()
91+
default:
92+
seq.mapAllOrFailures { $0.toTyped() }.mapError { $0.joinErrors() }.map(ShellString.concatOptimized)
93+
}
94+
}
95+
if let x = self as? AeroShellParser.SQuotedStringContext {
96+
return .success(.text(String(x.getText().dropFirst(1).dropLast(1))))
97+
}
98+
if let x = self as? AeroShellParser.SubstitutionContext {
99+
return x.program().toTyped("substitution node: nil child").map(ShellString.interpolation)
100+
}
101+
error("Unknown node type: \(self)")
102+
}
103+
}
104+
105+
extension AeroShellParser.DStringFragmentContext {
106+
func toTyped() -> Result<ShellString<String>, String> {
107+
if let x = ESCAPE_SEQUENCE() {
108+
return switch x.getText() {
109+
case "\\n": .success(.text("\n"))
110+
case "\\t": .success(.text("\t"))
111+
case "\\$": .success(.text("$"))
112+
case "\\\"": .success(.text("\""))
113+
case "\\\\": .success(.text("\\"))
114+
default: .failure("Unknown ESCAPE_SEQUENCE '\(x.getText())'")
115+
}
116+
}
117+
if let x = TEXT() {
118+
return .success(.text(x.getText()))
119+
}
120+
if let x = program() {
121+
return x.toTyped().map(ShellString.interpolation)
122+
}
123+
error("Unknown node type: \(self)")
124+
}
125+
}
126+
127+
private func binaryNode(
128+
_ op: (RawShell, RawShell) -> RawShell,
129+
_ a: AeroShellParser.ProgramContext?,
130+
_ b: AeroShellParser.ProgramContext?
131+
) -> Result<RawShell, String> {
132+
a.toTyped("binary node: nil child 0").combine { b.toTyped("binary node: nil child 1") }.map(op)
133+
}
134+
135+
extension Result {
136+
func combine<T>(_ other: () -> Result<T, Failure>) -> Result<(Success, T), Failure> {
137+
flatMap { a in
138+
other().flatMap { b in
139+
.success((a, b))
140+
}
141+
}
142+
}
143+
}
144+
145+
extension Result where Success == AeroShellParser.ProgramContext, Failure == String {
146+
func toTyped() -> Result<RawShell, String> { flatMap { $0.toTyped() } }
147+
}
148+
149+
private extension Optional where Wrapped == AeroShellParser.ProgramContext {
150+
func toTyped(_ msg: String) -> Result<RawShell, String> { orFailure(msg).toTyped() }
151+
}
152+
153+
class CmdMutableState {
154+
var stdin: String
155+
var env: [String: String]
156+
157+
init(stdin: String, pwd: String) {
158+
self.stdin = stdin
159+
self.env = config.execConfig.envVariables
160+
self.env["PWD"] = pwd
161+
}
162+
}
163+
164+
struct CmdOut {
165+
let stdout: [String]
166+
let exitCode: Int
167+
168+
static func succ(_ stdout: [String]) -> CmdOut { CmdOut(stdout: stdout, exitCode: 0) }
169+
static func fail(_ stdout: [String]) -> CmdOut { CmdOut(stdout: stdout, exitCode: 1) }
170+
}
171+
172+
// protocol AeroShell {
173+
// func run(_ state: CmdMutableState) -> CmdOut
174+
// }
175+
// extension [String] : AeroShell {
176+
// func run(_ state: CmdMutableState) -> CmdOut { .succ(self) }
177+
// }
178+
179+
extension Shell: Equatable where T: Equatable {}
180+
typealias AeroShell = Shell<any Command>
181+
typealias RawShell = Shell<String>
182+
indirect enum Shell<T> {
183+
case args([ShellString<T>])
184+
case empty
185+
186+
// Listed in precedence order
187+
case not(Shell<T>)
188+
case pipe(Shell<T>, Shell<T>)
189+
case and(Shell<T>, Shell<T>)
190+
case or(Shell<T>, Shell<T>)
191+
case seq([Shell<T>])
192+
}
193+
194+
extension ShellString: Equatable where T: Equatable {}
195+
enum ShellString<T> {
196+
case text(String)
197+
case interpolation(Shell<T>)
198+
case concat([ShellString<T>])
199+
200+
static func concatOptimized(_ fragments: [ShellString<T>]) -> ShellString<T> {
201+
var result: [ShellString<T>] = []
202+
var current: String = ""
203+
_concatOptimized(fragments, &result, &current)
204+
if !current.isEmpty {
205+
result.append(.text(current))
206+
}
207+
return result.singleOrNil() ?? .concat(result)
208+
}
209+
210+
private static func _concatOptimized(
211+
_ fragments: [ShellString<T>],
212+
_ result: inout [ShellString<T>],
213+
_ current: inout String
214+
) {
215+
for fragment in fragments {
216+
switch fragment {
217+
case .text(let text): current += text
218+
case .concat(let newFragments): _concatOptimized(newFragments, &result, &current)
219+
case .interpolation:
220+
if !current.isEmpty {
221+
result.append(.text(current))
222+
current = ""
223+
}
224+
result.append(fragment)
225+
}
226+
}
227+
}
228+
}

Sources/AppBundleTests/assert.swift

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import XCTest
2+
import Common
3+
4+
// Because XCTAssertEqual default messages are unreadable!
5+
func assertFailure<T, F>(_ r: Result<T, F>, file: String = #file, line: Int = #line) {
6+
switch r {
7+
case .success: failExpectedActual("Result.failure", r, file: file, line: line)
8+
case .failure: break
9+
}
10+
}
11+
12+
func assertEquals<T>( _ actual: T, _ expected: T, file: String = #file, line: Int = #line) where T: Equatable {
13+
if actual != expected {
14+
failExpectedActual(expected, actual, file: file, line: line)
15+
}
16+
}
17+
18+
private func failExpectedActual( _ expected: Any, _ actual: Any, file: String = #file, line: Int = #line) {
19+
XCTFail(
20+
"""
21+
22+
Assertion failed at \(file):\(line)
23+
Expected:
24+
\(expected)
25+
Actual:
26+
\(actual)
27+
"""
28+
)
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import XCTest
2+
import Common
3+
@testable import AppBundle
4+
5+
final class AeroShellTest: XCTestCase {
6+
func testParse() {
7+
let a: Shell = "a"
8+
let b: Shell = "b"
9+
let c: Shell = "c"
10+
let d: Shell = "d"
11+
let e: Shell = "e"
12+
let f: Shell = "f"
13+
let backslash = "\\"
14+
let space = " "
15+
16+
assertEquals("\"foo \(backslash)\" bar \(backslash)\(backslash)\(backslash)\(backslash)\" bar".parseShell().getOrThrow(), ["foo \" bar \(backslash)\(backslash)", "bar"])
17+
assertEquals(" ".parseShell().getOrThrow(), .empty)
18+
assertEquals("a | b && c | d".parseShell().getOrThrow(), .and(.pipe(a, b), .pipe(c, d)))
19+
assertEquals("foo && bar || a && baz".parseShell().getOrThrow(), .or(.and("foo", "bar"), .and("a", "baz")))
20+
assertEquals("foo a b; bar duh\n baz bro".parseShell().getOrThrow(), .seqV(["foo", "a", "b"], ["bar", "duh"], ["baz", "bro"]))
21+
assertEquals("(a || b) && (c || d)".parseShell().getOrThrow(), .and(.or(a, b), .or(c, d)))
22+
assertEquals("""
23+
a # comment 1
24+
b && c # comment 2
25+
d; # comment 3
26+
""".parseShell().getOrThrow(), .seqV(a, .and(b, c), d))
27+
assertEquals("""
28+
a && b # comment 1
29+
# comment 2
30+
|| c && d
31+
""".parseShell().getOrThrow(), .or(.and(a, b), .and(c, d)))
32+
assertEquals("""
33+
a \(backslash)\(space)
34+
b c \(backslash) # comment 2
35+
d && e \(backslash)
36+
&& f
37+
""".parseShell().getOrThrow(), .and(.and(["a", "b", "c", "d"], e), f))
38+
assertEquals("""
39+
echo "hi $(foo bar)"
40+
""".parseShell().getOrThrow(),
41+
.args([.text("echo"), .concatV(.text("hi "), .interpolation(["foo", "bar"]))])
42+
)
43+
44+
assertFailure("echo \"\"\"\"".parseShell())
45+
assertFailure("echo \"foo \(backslash)\"".parseShell())
46+
assertFailure("|| foo".parseShell())
47+
assertFailure("a && (b || c) foo".parseShell())
48+
}
49+
}
50+
51+
extension Shell: ExpressibleByUnicodeScalarLiteral where T == String { // Please Swift
52+
public init(unicodeScalarLiteral value: Self.UnicodeScalarLiteralType) { error("Unused") }
53+
}
54+
extension Shell: ExpressibleByExtendedGraphemeClusterLiteral where T == String { // Please Swift
55+
public init(extendedGraphemeClusterLiteral value: Self.ExtendedGraphemeClusterLiteralType) { error("Unused") }
56+
}
57+
extension Shell: ExpressibleByStringLiteral where T == String {
58+
public typealias StringLiteralType = String
59+
public init(stringLiteral: String) {
60+
self = .args([.text(stringLiteral)])
61+
}
62+
}
63+
64+
extension Shell: ExpressibleByArrayLiteral where T == String {
65+
public typealias ArrayLiteralElement = String
66+
public init(arrayLiteral elements: Self.ArrayLiteralElement...) {
67+
self = .args(elements.map(ShellString.text))
68+
}
69+
}
70+
71+
extension Shell {
72+
static func seqV(_ seq: Shell<T>...) -> Shell<T> { .seq(seq) }
73+
}
74+
75+
extension ShellString {
76+
static func concatV(_ fragments: ShellString<T>...) -> ShellString<T> { .concat(fragments) }
77+
}

0 commit comments

Comments
 (0)