mirror of
https://github.com/Nexoniarz/Finch.git
synced 2026-11-03 00:44:27 +00:00
Bootstrap: the Finch compiler written in Finch
boot/ is a second Finch compiler written in Finch (lexer, parser, type checker and an LLVM IR text generator, ~3k lines). Built by the C++ compiler it compiles itself; stage 2 and stage 3 produce byte-identical IR. It covers the core language: functions, structs, arrays, str, pointers, loops and modules (no C imports, sized numbers or defer), copies values like the main compiler and leaves freeing memory to the operating system. tests/boot.sh checks the fixpoint and runs the test programs it supports. Also: shell(cmd) built-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1dc976bd8f
commit
c7ddd4d8b6
8 changed files with 3153 additions and 1 deletions
94
boot/ast.fn
Normal file
94
boot/ast.fn
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
// The syntax tree of the self-hosted compiler.
|
||||||
|
|
||||||
|
// A type as written or resolved.
|
||||||
|
// kind: "void" "int" "bool" "char" "float" "str" "null" "ptr" "array" "named" "struct"
|
||||||
|
// elem: the element of an array or the target of a ptr (zero or one entry)
|
||||||
|
// name, module: structs ("" module = the main file)
|
||||||
|
struct Type {
|
||||||
|
str kind
|
||||||
|
[]Type elem
|
||||||
|
str name
|
||||||
|
str module
|
||||||
|
}
|
||||||
|
|
||||||
|
fn simple(str kind) -> Type {
|
||||||
|
return Type(kind: kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arrayOf(Type t) -> Type {
|
||||||
|
out := Type(kind: "array")
|
||||||
|
out.elem.push(t)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ptrTo(Type t) -> Type {
|
||||||
|
out := Type(kind: "ptr")
|
||||||
|
out.elem.push(t)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn same(Type a, Type b) -> bool {
|
||||||
|
if a.kind != b.kind {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.kind == "struct" || a.kind == "named" {
|
||||||
|
return a.name == b.name && a.module == b.module
|
||||||
|
}
|
||||||
|
if a.kind == "array" || a.kind == "ptr" {
|
||||||
|
if a.elem.len != b.elem.len {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.elem.len == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return same(a.elem[0], b.elem[0])
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show(Type t) -> str {
|
||||||
|
if t.kind == "array" {
|
||||||
|
return "[]" + show(t.elem[0])
|
||||||
|
}
|
||||||
|
if t.kind == "ptr" {
|
||||||
|
if t.elem.len == 0 {
|
||||||
|
return "ptr"
|
||||||
|
}
|
||||||
|
return "ptr[" + show(t.elem[0]) + "]"
|
||||||
|
}
|
||||||
|
if t.kind == "struct" || t.kind == "named" {
|
||||||
|
if t.module.len > 0 {
|
||||||
|
return t.module + "." + t.name
|
||||||
|
}
|
||||||
|
return t.name
|
||||||
|
}
|
||||||
|
if t.kind == "void" {
|
||||||
|
return "nothing"
|
||||||
|
}
|
||||||
|
return t.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
// One node of the tree, for expressions, statements, functions and structs alike.
|
||||||
|
// expressions: int float str char bool null var unary binary call member index array method
|
||||||
|
// statements: decl assign expr if while for each return break continue block
|
||||||
|
// top level: fn param struct field
|
||||||
|
struct Node {
|
||||||
|
str kind
|
||||||
|
str name // identifier, operator, callee, field or method name
|
||||||
|
str text // literal text
|
||||||
|
Type ty // declared type (decl, param, field, fn result)
|
||||||
|
bool flag // decl: has a type; fn: has a body
|
||||||
|
[]Node kids
|
||||||
|
[]str names // named arguments: Point(x: 1)
|
||||||
|
int line
|
||||||
|
int col
|
||||||
|
int file
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Program {
|
||||||
|
str module
|
||||||
|
str path
|
||||||
|
[]str imports // Finch modules
|
||||||
|
[]Node structs
|
||||||
|
[]Node fns
|
||||||
|
}
|
||||||
2161
boot/gen.fn
Normal file
2161
boot/gen.fn
Normal file
File diff suppressed because it is too large
Load diff
210
boot/lexer.fn
Normal file
210
boot/lexer.fn
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
// The Finch lexer, written in Finch.
|
||||||
|
// A token's kind is a word: "ident", "int", "float", "string", "char", "eof",
|
||||||
|
// or the keyword / operator itself ("fn", "{", "+=", ...).
|
||||||
|
|
||||||
|
struct Token {
|
||||||
|
str kind
|
||||||
|
str text
|
||||||
|
int line
|
||||||
|
int col
|
||||||
|
bool nl // a newline came before this token
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isAlpha(char c) -> bool {
|
||||||
|
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isDigit(char c) -> bool {
|
||||||
|
return c >= '0' && c <= '9'
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isHex(char c) -> bool {
|
||||||
|
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isKeyword(str w) -> bool {
|
||||||
|
keywords := ["fn", "return", "if", "else", "while", "for", "in", "break", "continue", "true", "false",
|
||||||
|
"null", "import", "link", "struct", "defer"]
|
||||||
|
return keywords.contains(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lexer {
|
||||||
|
str src
|
||||||
|
str path
|
||||||
|
int i
|
||||||
|
int line
|
||||||
|
int col
|
||||||
|
bool nl
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(ptr[Lexer] lx, int line, int col, str msg) {
|
||||||
|
print(lx.path + ":" + str(line) + ":" + str(col) + ": error: " + msg)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peek(ptr[Lexer] lx, int off) -> char {
|
||||||
|
if lx.i + off < lx.src.len {
|
||||||
|
return lx.src[lx.i + off]
|
||||||
|
}
|
||||||
|
return char(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance(ptr[Lexer] lx) {
|
||||||
|
c := lx.src[lx.i]
|
||||||
|
if c == '\n' {
|
||||||
|
lx.line += 1
|
||||||
|
lx.col = 1
|
||||||
|
lx.nl = true
|
||||||
|
} else if int(c) & 0xC0 != 0x80 {
|
||||||
|
lx.col += 1
|
||||||
|
}
|
||||||
|
lx.i += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// operators, longest first
|
||||||
|
fn operator(ptr[Lexer] lx) -> str {
|
||||||
|
twos := ["->", "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "..", ":=", "+=", "-=", "*=", "/=", "%="]
|
||||||
|
if lx.i + 1 < lx.src.len {
|
||||||
|
two := lx.src.sub(lx.i, lx.i + 2)
|
||||||
|
if twos.contains(two) {
|
||||||
|
return two
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ones := "(){}[],.:+-*/%&|^~<>=!"
|
||||||
|
c := lx.src.sub(lx.i, lx.i + 1)
|
||||||
|
if ones.contains(c) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape(ptr[Lexer] lx, char e) -> char {
|
||||||
|
if e == 'n' {
|
||||||
|
return '\n'
|
||||||
|
}
|
||||||
|
if e == 't' {
|
||||||
|
return '\t'
|
||||||
|
}
|
||||||
|
if e == 'r' {
|
||||||
|
return '\r'
|
||||||
|
}
|
||||||
|
if e == '0' {
|
||||||
|
return char(0)
|
||||||
|
}
|
||||||
|
if e == '\\' || e == '"' || e == '\'' {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
fail(lx, lx.line, lx.col - 1, "unknown escape \\" + str(e))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lex(str src, str path) -> []Token {
|
||||||
|
lexer := Lexer(src: src, path: path, i: 0, line: 1, col: 1, nl: true)
|
||||||
|
lx := addr(lexer)
|
||||||
|
[]Token out
|
||||||
|
while lx.i < lx.src.len {
|
||||||
|
c := lx.src[lx.i]
|
||||||
|
if c == ' ' || c == '\t' || c == '\n' || c == '\r' {
|
||||||
|
advance(lx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '/' && peek(lx, 1) == '/' {
|
||||||
|
while lx.i < lx.src.len && lx.src[lx.i] != '\n' {
|
||||||
|
advance(lx)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '/' && peek(lx, 1) == '*' {
|
||||||
|
line := lx.line
|
||||||
|
col := lx.col
|
||||||
|
advance(lx)
|
||||||
|
advance(lx)
|
||||||
|
while lx.i < lx.src.len && !(lx.src[lx.i] == '*' && peek(lx, 1) == '/') {
|
||||||
|
advance(lx)
|
||||||
|
}
|
||||||
|
if lx.i >= lx.src.len {
|
||||||
|
fail(lx, line, col, "comment is never closed (missing */)")
|
||||||
|
}
|
||||||
|
advance(lx)
|
||||||
|
advance(lx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
t := Token(kind: "", text: "", line: lx.line, col: lx.col, nl: lx.nl)
|
||||||
|
lx.nl = false
|
||||||
|
start := lx.i
|
||||||
|
if isAlpha(c) {
|
||||||
|
while lx.i < lx.src.len && (isAlpha(lx.src[lx.i]) || isDigit(lx.src[lx.i])) {
|
||||||
|
advance(lx)
|
||||||
|
}
|
||||||
|
t.text = lx.src.sub(start, lx.i)
|
||||||
|
t.kind = "ident"
|
||||||
|
if isKeyword(t.text) {
|
||||||
|
t.kind = t.text
|
||||||
|
}
|
||||||
|
} else if isDigit(c) {
|
||||||
|
t.kind = "int"
|
||||||
|
if c == '0' && (peek(lx, 1) == 'x' || peek(lx, 1) == 'X') {
|
||||||
|
advance(lx)
|
||||||
|
advance(lx)
|
||||||
|
while lx.i < lx.src.len && (isHex(lx.src[lx.i]) || lx.src[lx.i] == '_') {
|
||||||
|
advance(lx)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
while lx.i < lx.src.len && (isDigit(lx.src[lx.i]) || lx.src[lx.i] == '_') {
|
||||||
|
advance(lx)
|
||||||
|
}
|
||||||
|
if peek(lx, 0) == '.' && isDigit(peek(lx, 1)) {
|
||||||
|
t.kind = "float"
|
||||||
|
advance(lx)
|
||||||
|
while lx.i < lx.src.len && (isDigit(lx.src[lx.i]) || lx.src[lx.i] == '_') {
|
||||||
|
advance(lx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.text = lx.src.sub(start, lx.i).replace("_", "")
|
||||||
|
} else if c == '"' || c == '\'' {
|
||||||
|
quote := c
|
||||||
|
t.kind = "string"
|
||||||
|
if quote == '\'' {
|
||||||
|
t.kind = "char"
|
||||||
|
}
|
||||||
|
advance(lx)
|
||||||
|
[]str parts
|
||||||
|
while peek(lx, 0) != quote {
|
||||||
|
if peek(lx, 0) == char(0) || peek(lx, 0) == '\n' {
|
||||||
|
if quote == '"' {
|
||||||
|
fail(lx, t.line, t.col, "string is never closed (missing \")")
|
||||||
|
}
|
||||||
|
fail(lx, t.line, t.col, "char is never closed (missing ')")
|
||||||
|
}
|
||||||
|
ch := peek(lx, 0)
|
||||||
|
advance(lx)
|
||||||
|
if ch == '\\' {
|
||||||
|
e := peek(lx, 0)
|
||||||
|
advance(lx)
|
||||||
|
ch = escape(lx, e)
|
||||||
|
}
|
||||||
|
parts.push(str(ch))
|
||||||
|
}
|
||||||
|
advance(lx)
|
||||||
|
t.text = parts.join("")
|
||||||
|
if quote == '\'' && t.text.len != 1 {
|
||||||
|
fail(lx, t.line, t.col, "a char holds exactly one character, like 'a'")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
op := operator(lx)
|
||||||
|
if op.len == 0 {
|
||||||
|
fail(lx, t.line, t.col, "unexpected character '" + str(c) + "'")
|
||||||
|
}
|
||||||
|
for k in 0..op.len {
|
||||||
|
advance(lx)
|
||||||
|
}
|
||||||
|
t.kind = op
|
||||||
|
t.text = op
|
||||||
|
}
|
||||||
|
out.push(t)
|
||||||
|
}
|
||||||
|
out.push(Token(kind: "eof", text: "", line: lx.line, col: lx.col, nl: true))
|
||||||
|
return out
|
||||||
|
}
|
||||||
116
boot/main.fn
Normal file
116
boot/main.fn
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
// finch-boot: the Finch compiler written in Finch.
|
||||||
|
//
|
||||||
|
// finch-boot build <file.fn> [-o name] compile a program
|
||||||
|
// finch-boot ir <file.fn> print the LLVM IR
|
||||||
|
//
|
||||||
|
// It reads the program and the modules it imports, checks it, writes LLVM IR as text
|
||||||
|
// and lets clang turn that into a program, linked with Finch's C runtime.
|
||||||
|
import lexer
|
||||||
|
import ast
|
||||||
|
import parser
|
||||||
|
import gen
|
||||||
|
|
||||||
|
fn dirOf(str path) -> str {
|
||||||
|
i := path.len - 1
|
||||||
|
while i >= 0 && path[i] != '/' {
|
||||||
|
i -= 1
|
||||||
|
}
|
||||||
|
if i < 0 {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
return path.sub(0, i)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stem(str path) -> str {
|
||||||
|
name := path.sub(dirOf(path).len + 1, path.len)
|
||||||
|
if dirOf(path) == "." && !path.starts_with("./") {
|
||||||
|
name = path
|
||||||
|
}
|
||||||
|
dot := name.find(".")
|
||||||
|
if dot > 0 {
|
||||||
|
return name.sub(0, dot)
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Loader {
|
||||||
|
[]ast.Program progs
|
||||||
|
[]str files
|
||||||
|
[]str loaded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(ptr[Loader] l, str path, str module) {
|
||||||
|
if !file_exists(path) {
|
||||||
|
if module.len > 0 {
|
||||||
|
print("error: can't find the module '" + module + "' (looked for " + path + ")")
|
||||||
|
} else {
|
||||||
|
print("error: can't open '" + path + "'")
|
||||||
|
}
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
src := read_file(path)
|
||||||
|
file := l.files.len
|
||||||
|
l.files.push(path)
|
||||||
|
prog := parser.parse(lexer.lex(src, path), file, path)
|
||||||
|
prog.module = module
|
||||||
|
prog.path = path
|
||||||
|
imports := prog.imports
|
||||||
|
l.progs.push(prog)
|
||||||
|
for name in imports {
|
||||||
|
if !l.loaded.contains(name) {
|
||||||
|
l.loaded.push(name)
|
||||||
|
load(l, dirOf(path) + "/" + name + ".fn", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() {
|
||||||
|
print("finch-boot - the Finch compiler written in Finch")
|
||||||
|
print("usage: finch-boot build <file.fn> [-o name]")
|
||||||
|
print(" finch-boot ir <file.fn>")
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main([]str args) -> int {
|
||||||
|
if args.len < 3 {
|
||||||
|
usage()
|
||||||
|
}
|
||||||
|
cmd := args[1]
|
||||||
|
file := args[2]
|
||||||
|
out := stem(file)
|
||||||
|
if args.len == 5 && args[3] == "-o" {
|
||||||
|
out = args[4]
|
||||||
|
} else if args.len != 3 {
|
||||||
|
usage()
|
||||||
|
}
|
||||||
|
if cmd != "build" && cmd != "ir" {
|
||||||
|
usage()
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := Loader()
|
||||||
|
load(addr(loader), file, "")
|
||||||
|
ir := gen.generate(loader.progs, loader.files)
|
||||||
|
if cmd == "ir" {
|
||||||
|
print(ir)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// the runtime is Finch's C file; FINCH_RT can point somewhere else
|
||||||
|
rt := dirOf(args[0]) + "/../runtime/finch_rt.c"
|
||||||
|
if file_exists("runtime/finch_rt.c") {
|
||||||
|
rt = "runtime/finch_rt.c"
|
||||||
|
}
|
||||||
|
if !file_exists(rt) {
|
||||||
|
print("error: can't find the Finch runtime (runtime/finch_rt.c)")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
llPath := out + ".ll"
|
||||||
|
write_file(llPath, ir)
|
||||||
|
code := shell("clang -O2 -w '" + llPath + "' '" + rt + "' -o '" + out + "' -lm")
|
||||||
|
shell("rm -f '" + llPath + "'")
|
||||||
|
if code != 0 {
|
||||||
|
print("error: clang failed")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
501
boot/parser.fn
Normal file
501
boot/parser.fn
Normal file
|
|
@ -0,0 +1,501 @@
|
||||||
|
// The Finch parser, written in Finch: tokens -> ast.Program.
|
||||||
|
import lexer
|
||||||
|
import ast
|
||||||
|
|
||||||
|
struct Parser {
|
||||||
|
[]lexer.Token toks
|
||||||
|
int i
|
||||||
|
int depth // inside ( ) and [ ] newlines don't end anything
|
||||||
|
int file
|
||||||
|
str path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fail(ptr[Parser] p, lexer.Token t, str msg) {
|
||||||
|
print(p.path + ":" + str(t.line) + ":" + str(t.col) + ": error: " + msg)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cur(ptr[Parser] p) -> lexer.Token {
|
||||||
|
return p.toks[p.i]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn curKind(ptr[Parser] p) -> str {
|
||||||
|
return p.toks[p.i].kind
|
||||||
|
}
|
||||||
|
|
||||||
|
fn peekKind(ptr[Parser] p, int off) -> str {
|
||||||
|
k := p.i + off
|
||||||
|
if k >= p.toks.len {
|
||||||
|
k = p.toks.len - 1
|
||||||
|
}
|
||||||
|
return p.toks[k].kind
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(ptr[Parser] p, str k) -> bool {
|
||||||
|
return p.toks[p.i].kind == k
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next(ptr[Parser] p) -> lexer.Token {
|
||||||
|
t := p.toks[p.i]
|
||||||
|
p.i += 1
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept(ptr[Parser] p, str k) -> bool {
|
||||||
|
if at(p, k) {
|
||||||
|
p.i += 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn describe(lexer.Token t) -> str {
|
||||||
|
if t.kind == "ident" {
|
||||||
|
return "'" + t.text + "'"
|
||||||
|
}
|
||||||
|
if t.kind == "eof" {
|
||||||
|
return "end of file"
|
||||||
|
}
|
||||||
|
if t.kind == "int" || t.kind == "float" {
|
||||||
|
return "a number"
|
||||||
|
}
|
||||||
|
if t.kind == "string" {
|
||||||
|
return "a string"
|
||||||
|
}
|
||||||
|
if t.kind == "char" {
|
||||||
|
return "a char"
|
||||||
|
}
|
||||||
|
return "'" + t.kind + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn expect(ptr[Parser] p, str k, str what) -> lexer.Token {
|
||||||
|
if !at(p, k) {
|
||||||
|
fail(p, cur(p), "expected " + what + ", found " + describe(cur(p)))
|
||||||
|
}
|
||||||
|
return next(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sameLine(ptr[Parser] p) -> bool {
|
||||||
|
return p.depth > 0 || !p.toks[p.i].nl
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node(ptr[Parser] p, str kind) -> ast.Node {
|
||||||
|
t := p.toks[p.i]
|
||||||
|
return ast.Node(kind: kind, line: t.line, col: t.col, file: p.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isTypeName(str n) -> bool {
|
||||||
|
return n == "int" || n == "float" || n == "bool" || n == "char" || n == "str" || n == "ptr"
|
||||||
|
}
|
||||||
|
|
||||||
|
// int, str, ptr[T], []T, Name, module.Name
|
||||||
|
fn parseType(ptr[Parser] p) -> ast.Type {
|
||||||
|
if accept(p, "[") {
|
||||||
|
expect(p, "]", "']' (an array type looks like []int)")
|
||||||
|
return ast.arrayOf(parseType(p))
|
||||||
|
}
|
||||||
|
if !at(p, "ident") {
|
||||||
|
fail(p, cur(p), "unexpected " + describe(cur(p)) + " (expected a type like int, str, []int or a struct name)")
|
||||||
|
}
|
||||||
|
name := next(p).text
|
||||||
|
if isTypeName(name) {
|
||||||
|
if name == "ptr" && accept(p, "[") {
|
||||||
|
t := ast.ptrTo(parseType(p))
|
||||||
|
expect(p, "]", "']'")
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
return ast.simple(name)
|
||||||
|
}
|
||||||
|
t := ast.Type(kind: "named", name: name)
|
||||||
|
if at(p, ".") && peekKind(p, 1) == "ident" {
|
||||||
|
next(p)
|
||||||
|
t.module = name
|
||||||
|
t.name = next(p).text
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn atDeclaration(ptr[Parser] p) -> bool {
|
||||||
|
if at(p, "[") {
|
||||||
|
return peekKind(p, 1) == "]"
|
||||||
|
}
|
||||||
|
if !at(p, "ident") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
n := peekKind(p, 1)
|
||||||
|
if n == "ident" {
|
||||||
|
return !p.toks[p.i + 1].nl
|
||||||
|
}
|
||||||
|
if cur(p).text == "ptr" && n == "[" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return n == "." && peekKind(p, 2) == "ident" && peekKind(p, 3) == "ident" && !p.toks[p.i + 3].nl
|
||||||
|
}
|
||||||
|
|
||||||
|
fn endOfStatement(ptr[Parser] p) {
|
||||||
|
if at(p, "}") || at(p, "eof") || cur(p).nl {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fail(p, cur(p), "unexpected " + describe(cur(p)) + " (put each statement on its own line)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- top level ----------
|
||||||
|
|
||||||
|
fn parseProgram(ptr[Parser] p) -> ast.Program {
|
||||||
|
prog := ast.Program()
|
||||||
|
while !at(p, "eof") {
|
||||||
|
if at(p, "import") {
|
||||||
|
t := next(p)
|
||||||
|
if !at(p, "ident") {
|
||||||
|
fail(p, t, "the self-hosted compiler only imports Finch modules (import name)")
|
||||||
|
}
|
||||||
|
prog.imports.push(next(p).text)
|
||||||
|
} else if at(p, "struct") {
|
||||||
|
prog.structs.push(parseStruct(p))
|
||||||
|
} else {
|
||||||
|
prog.fns.push(parseFn(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return prog
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parseStruct(ptr[Parser] p) -> ast.Node {
|
||||||
|
s := node(p, "struct")
|
||||||
|
expect(p, "struct", "'struct'")
|
||||||
|
s.name = expect(p, "ident", "a struct name").text
|
||||||
|
expect(p, "{", "'{'")
|
||||||
|
while !at(p, "}") {
|
||||||
|
if at(p, "eof") {
|
||||||
|
fail(p, cur(p), "a struct is never closed (missing '}')")
|
||||||
|
}
|
||||||
|
f := node(p, "field")
|
||||||
|
if !atDeclaration(p) {
|
||||||
|
fail(p, cur(p), "unexpected " + describe(cur(p)) + " (a field looks like: int x)")
|
||||||
|
}
|
||||||
|
f.ty = parseType(p)
|
||||||
|
f.name = expect(p, "ident", "a field name").text
|
||||||
|
if accept(p, "=") {
|
||||||
|
f.kids.push(parseExpr(p))
|
||||||
|
}
|
||||||
|
s.kids.push(f)
|
||||||
|
endOfStatement(p)
|
||||||
|
}
|
||||||
|
next(p)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parseFn(ptr[Parser] p) -> ast.Node {
|
||||||
|
f := node(p, "fn")
|
||||||
|
expect(p, "fn", "'fn', 'struct' or 'import'")
|
||||||
|
f.name = expect(p, "ident", "a function name").text
|
||||||
|
expect(p, "(", "'('")
|
||||||
|
p.depth += 1
|
||||||
|
if !at(p, ")") {
|
||||||
|
while true {
|
||||||
|
prm := node(p, "param")
|
||||||
|
prm.ty = parseType(p)
|
||||||
|
prm.name = expect(p, "ident", "a parameter name").text
|
||||||
|
f.kids.push(prm)
|
||||||
|
if !accept(p, ",") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.depth -= 1
|
||||||
|
expect(p, ")", "')'")
|
||||||
|
f.ty = ast.simple("void")
|
||||||
|
if accept(p, "->") {
|
||||||
|
f.ty = parseType(p)
|
||||||
|
}
|
||||||
|
f.kids.push(parseBlock(p))
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- statements ----------
|
||||||
|
|
||||||
|
fn parseBlock(ptr[Parser] p) -> ast.Node {
|
||||||
|
blk := node(p, "block")
|
||||||
|
expect(p, "{", "'{'")
|
||||||
|
saved := p.depth
|
||||||
|
p.depth = 0
|
||||||
|
while !at(p, "}") {
|
||||||
|
if at(p, "eof") {
|
||||||
|
fail(p, cur(p), "a '{' is never closed (missing '}')")
|
||||||
|
}
|
||||||
|
blk.kids.push(parseStatement(p))
|
||||||
|
endOfStatement(p)
|
||||||
|
}
|
||||||
|
p.depth = saved
|
||||||
|
next(p)
|
||||||
|
return blk
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parseStatement(ptr[Parser] p) -> ast.Node {
|
||||||
|
k := curKind(p)
|
||||||
|
if k == "if" {
|
||||||
|
return parseIf(p)
|
||||||
|
}
|
||||||
|
if k == "while" {
|
||||||
|
s := node(p, "while")
|
||||||
|
next(p)
|
||||||
|
s.kids.push(parseExpr(p))
|
||||||
|
s.kids.push(parseBlock(p))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if k == "for" {
|
||||||
|
s := node(p, "for")
|
||||||
|
next(p)
|
||||||
|
s.name = expect(p, "ident", "a loop variable name").text
|
||||||
|
expect(p, "in", "'in'")
|
||||||
|
s.kids.push(parseExpr(p))
|
||||||
|
if accept(p, "..") {
|
||||||
|
s.kids.push(parseExpr(p))
|
||||||
|
} else {
|
||||||
|
s.kind = "each"
|
||||||
|
}
|
||||||
|
s.kids.push(parseBlock(p))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if k == "return" {
|
||||||
|
s := node(p, "return")
|
||||||
|
next(p)
|
||||||
|
if !at(p, "}") && !cur(p).nl {
|
||||||
|
s.kids.push(parseExpr(p))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if k == "break" || k == "continue" {
|
||||||
|
s := node(p, k)
|
||||||
|
next(p)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if k == "{" {
|
||||||
|
return parseBlock(p)
|
||||||
|
}
|
||||||
|
if k == "fn" || k == "struct" || k == "import" || k == "defer" || k == "link" {
|
||||||
|
fail(p, cur(p), "'" + k + "' can't be used here in the self-hosted compiler")
|
||||||
|
}
|
||||||
|
|
||||||
|
if atDeclaration(p) {
|
||||||
|
s := node(p, "decl")
|
||||||
|
s.flag = true
|
||||||
|
s.ty = parseType(p)
|
||||||
|
s.name = expect(p, "ident", "a variable name").text
|
||||||
|
if accept(p, "=") {
|
||||||
|
s.kids.push(parseExpr(p))
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if at(p, "ident") && peekKind(p, 1) == ":=" {
|
||||||
|
s := node(p, "decl")
|
||||||
|
s.name = next(p).text
|
||||||
|
next(p)
|
||||||
|
s.kids.push(parseExpr(p))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
start := cur(p)
|
||||||
|
e := parseExpr(p)
|
||||||
|
ops := ["=", "+=", "-=", "*=", "/=", "%="]
|
||||||
|
if ops.contains(curKind(p)) && sameLine(p) {
|
||||||
|
if e.kind != "var" && e.kind != "member" && e.kind != "index" {
|
||||||
|
fail(p, start, "only a variable, a field, an element or p.value can be assigned to")
|
||||||
|
}
|
||||||
|
s := ast.Node(kind: "assign", line: start.line, col: start.col, file: p.file)
|
||||||
|
s.name = next(p).kind
|
||||||
|
s.kids.push(e)
|
||||||
|
s.kids.push(parseExpr(p))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if e.kind != "call" && e.kind != "method" {
|
||||||
|
fail(p, start, "this value is computed but never used")
|
||||||
|
}
|
||||||
|
s := ast.Node(kind: "expr", line: start.line, col: start.col, file: p.file)
|
||||||
|
s.kids.push(e)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parseIf(ptr[Parser] p) -> ast.Node {
|
||||||
|
s := node(p, "if")
|
||||||
|
expect(p, "if", "'if'")
|
||||||
|
s.kids.push(parseExpr(p))
|
||||||
|
s.kids.push(parseBlock(p))
|
||||||
|
if accept(p, "else") {
|
||||||
|
if at(p, "if") {
|
||||||
|
s.kids.push(parseIf(p))
|
||||||
|
} else {
|
||||||
|
s.kids.push(parseBlock(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- expressions ----------
|
||||||
|
|
||||||
|
fn binaryLevel(str op) -> int {
|
||||||
|
if op == "||" {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if op == "&&" {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if op == "==" || op == "!=" || op == "<" || op == "<=" || op == ">" || op == ">=" {
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
if op == "+" || op == "-" || op == "|" || op == "^" {
|
||||||
|
return 4
|
||||||
|
}
|
||||||
|
if op == "*" || op == "/" || op == "%" || op == "<<" || op == ">>" || op == "&" {
|
||||||
|
return 5
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parseExpr(ptr[Parser] p) -> ast.Node {
|
||||||
|
return parseBinary(p, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// precedence climbing; all binary operators are left-associative
|
||||||
|
fn parseBinary(ptr[Parser] p, int level) -> ast.Node {
|
||||||
|
if level > 5 {
|
||||||
|
return parseUnary(p)
|
||||||
|
}
|
||||||
|
left := parseBinary(p, level + 1)
|
||||||
|
while sameLine(p) && binaryLevel(curKind(p)) == level {
|
||||||
|
e := node(p, "binary")
|
||||||
|
e.name = next(p).kind
|
||||||
|
e.kids.push(left)
|
||||||
|
e.kids.push(parseBinary(p, level + 1))
|
||||||
|
left = e
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parseUnary(ptr[Parser] p) -> ast.Node {
|
||||||
|
if at(p, "-") || at(p, "!") || at(p, "~") {
|
||||||
|
e := node(p, "unary")
|
||||||
|
e.name = next(p).kind
|
||||||
|
e.kids.push(parseUnary(p))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
return parsePostfix(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parseArgs(ptr[Parser] p, ptr[ast.Node] call) {
|
||||||
|
expect(p, "(", "'('")
|
||||||
|
p.depth += 1
|
||||||
|
if !at(p, ")") {
|
||||||
|
while true {
|
||||||
|
name := ""
|
||||||
|
if at(p, "ident") && peekKind(p, 1) == ":" {
|
||||||
|
name = next(p).text
|
||||||
|
next(p)
|
||||||
|
}
|
||||||
|
call.names.push(name)
|
||||||
|
call.kids.push(parseExpr(p))
|
||||||
|
if !accept(p, ",") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.depth -= 1
|
||||||
|
expect(p, ")", "')'")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parsePostfix(ptr[Parser] p) -> ast.Node {
|
||||||
|
e := parsePrimary(p)
|
||||||
|
while sameLine(p) {
|
||||||
|
if at(p, ".") {
|
||||||
|
m := node(p, "member")
|
||||||
|
next(p)
|
||||||
|
m.name = expect(p, "ident", "a name after '.'").text
|
||||||
|
m.kids.push(e)
|
||||||
|
if at(p, "(") && !cur(p).nl {
|
||||||
|
m.kind = "method"
|
||||||
|
parseArgs(p, addr(m))
|
||||||
|
}
|
||||||
|
e = m
|
||||||
|
} else if at(p, "[") {
|
||||||
|
ix := node(p, "index")
|
||||||
|
next(p)
|
||||||
|
p.depth += 1
|
||||||
|
ix.kids.push(e)
|
||||||
|
ix.kids.push(parseExpr(p))
|
||||||
|
p.depth -= 1
|
||||||
|
expect(p, "]", "']'")
|
||||||
|
e = ix
|
||||||
|
} else {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parsePrimary(ptr[Parser] p) -> ast.Node {
|
||||||
|
t := cur(p)
|
||||||
|
e := node(p, "")
|
||||||
|
k := t.kind
|
||||||
|
if k == "int" || k == "float" || k == "char" {
|
||||||
|
next(p)
|
||||||
|
e.kind = k
|
||||||
|
e.text = t.text
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if k == "string" {
|
||||||
|
next(p)
|
||||||
|
e.kind = "str"
|
||||||
|
e.text = t.text
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if k == "true" || k == "false" {
|
||||||
|
next(p)
|
||||||
|
e.kind = "bool"
|
||||||
|
e.text = k
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if k == "null" {
|
||||||
|
next(p)
|
||||||
|
e.kind = "null"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if k == "(" {
|
||||||
|
next(p)
|
||||||
|
p.depth += 1
|
||||||
|
inner := parseExpr(p)
|
||||||
|
p.depth -= 1
|
||||||
|
expect(p, ")", "')'")
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
if k == "[" {
|
||||||
|
next(p)
|
||||||
|
e.kind = "array"
|
||||||
|
p.depth += 1
|
||||||
|
while !at(p, "]") {
|
||||||
|
e.kids.push(parseExpr(p))
|
||||||
|
if !accept(p, ",") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.depth -= 1
|
||||||
|
expect(p, "]", "']'")
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if k == "ident" {
|
||||||
|
next(p)
|
||||||
|
e.name = t.text
|
||||||
|
if at(p, "(") && !cur(p).nl {
|
||||||
|
e.kind = "call"
|
||||||
|
parseArgs(p, addr(e))
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
e.kind = "var"
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
fail(p, t, "unexpected " + describe(t) + " (expected a value)")
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse([]lexer.Token toks, int file, str path) -> ast.Program {
|
||||||
|
parser := Parser(toks: toks, i: 0, depth: 0, file: file, path: path)
|
||||||
|
return parseProgram(addr(parser))
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char *ptr;
|
char *ptr;
|
||||||
|
|
@ -418,6 +419,15 @@ void finch_read_file(FStr *out, const FStr *path, const char *file, int64_t line
|
||||||
out->cap = size + 1;
|
out->cap = size + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shell("ls -l"): run a command, give back its exit code
|
||||||
|
int64_t finch_shell(const FStr *cmd) {
|
||||||
|
fflush(stdout);
|
||||||
|
int status = system(cmd->ptr ? cmd->ptr : "");
|
||||||
|
if (status == -1) return -1;
|
||||||
|
if (WIFEXITED(status)) return WEXITSTATUS(status);
|
||||||
|
return 128 + (WIFSIGNALED(status) ? WTERMSIG(status) : 0);
|
||||||
|
}
|
||||||
|
|
||||||
int finch_write_file(const FStr *path, const FStr *text) {
|
int finch_write_file(const FStr *path, const FStr *text) {
|
||||||
FILE *f = fopen(path->ptr, "wb");
|
FILE *f = fopen(path->ptr, "wb");
|
||||||
if (!f) return 0;
|
if (!f) return 0;
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ namespace finch {
|
||||||
|
|
||||||
bool Codegen::isBuiltin(const std::string &n) {
|
bool Codegen::isBuiltin(const std::string &n) {
|
||||||
static const std::set<std::string> names = {"print", "addr", "input", "new", "free", "read_file", "write_file",
|
static const std::set<std::string> names = {"print", "addr", "input", "new", "free", "read_file", "write_file",
|
||||||
"file_exists", "exit"};
|
"file_exists", "exit", "shell"};
|
||||||
FType t;
|
FType t;
|
||||||
return names.count(n) || typeFromName(n, t);
|
return names.count(n) || typeFromName(n, t);
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +91,7 @@ Function *Codegen::rt(const std::string &name) {
|
||||||
{"finch_file_exists", {I32, {P}}},
|
{"finch_file_exists", {I32, {P}}},
|
||||||
{"finch_read_file", {V, {P, P, P, I}}},
|
{"finch_read_file", {V, {P, P, P, I}}},
|
||||||
{"finch_write_file", {I32, {P, P}}},
|
{"finch_write_file", {I32, {P, P}}},
|
||||||
|
{"finch_shell", {I, {P}}},
|
||||||
};
|
};
|
||||||
auto it = sigs.find(name);
|
auto it = sigs.find(name);
|
||||||
if (it == sigs.end()) {
|
if (it == sigs.end()) {
|
||||||
|
|
@ -229,6 +230,15 @@ Value_ Codegen::call(const CallExpr &c) {
|
||||||
release(path);
|
release(path);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
if (n == "shell") {
|
||||||
|
Value_ cmd = coerce(expr(one("command")), FType::Str, c.args[0]->pos, "the command");
|
||||||
|
Value *ss = tmpOf(ty(FType::Str));
|
||||||
|
b.CreateCall(rt("finch_str_copy"), {ss, tmp(cmd.v)}); // a heap copy is always NUL-terminated
|
||||||
|
Value *r = b.CreateCall(rt("finch_shell"), {ss});
|
||||||
|
dropAt(ss, FType::Str);
|
||||||
|
release(cmd);
|
||||||
|
return {r, FType::I64};
|
||||||
|
}
|
||||||
if (n == "write_file") {
|
if (n == "write_file") {
|
||||||
checkArgs(c.args, c.argNames, 2, p, n);
|
checkArgs(c.args, c.argNames, 2, p, n);
|
||||||
Value_ path = coerce(expr(*c.args[0]), FType::Str, c.args[0]->pos, "the file name");
|
Value_ path = coerce(expr(*c.args[0]), FType::Str, c.args[0]->pos, "the file name");
|
||||||
|
|
|
||||||
50
tests/boot.sh
Executable file
50
tests/boot.sh
Executable file
|
|
@ -0,0 +1,50 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# The self-hosted compiler (boot/) must
|
||||||
|
# 1. build itself: stage1 (built by build/finch) -> stage2 -> stage3, with identical IR
|
||||||
|
# 2. give the same output as build/finch on every test in tests/run it supports
|
||||||
|
set -u
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|
||||||
|
build/finch build boot/main.fn -o "$tmp/boot1" || exit 1
|
||||||
|
"$tmp/boot1" build boot/main.fn -o "$tmp/boot2" || exit 1
|
||||||
|
"$tmp/boot1" ir boot/main.fn > "$tmp/stage2.ll"
|
||||||
|
"$tmp/boot2" ir boot/main.fn > "$tmp/stage3.ll"
|
||||||
|
if cmp -s "$tmp/stage2.ll" "$tmp/stage3.ll"; then
|
||||||
|
echo "bootstrap: stage 2 and stage 3 are identical ($(wc -l < "$tmp/stage3.ll") lines of IR)"
|
||||||
|
else
|
||||||
|
echo "bootstrap: FAILED, stage 2 and stage 3 differ"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
pass=0; failed=0; skipped=0
|
||||||
|
for f in tests/run/*.fn; do
|
||||||
|
grep -q "^fn main" "$f" || continue
|
||||||
|
# the bootstrap compiler doesn't do C imports, sized numbers or defer
|
||||||
|
# (checked in the test and in the modules it imports)
|
||||||
|
files="$f"
|
||||||
|
for m in $(sed -n 's/^import \([a-z_]*\)$/\1/p' "$f"); do files="$files tests/run/$m.fn"; done
|
||||||
|
if grep -qE '^import "|^link |\b(u8|u16|u32|u64|i8|i16|i32|f32|f64)\b|defer |0x' $files; then
|
||||||
|
skipped=$((skipped + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
input="${f%.fn}.in"
|
||||||
|
[ -f "$input" ] || input=/dev/null
|
||||||
|
if ! "$tmp/boot2" build "$f" -o "$tmp/prog" > "$tmp/err" 2>&1; then
|
||||||
|
failed=$((failed + 1))
|
||||||
|
echo "FAIL (compile) $f"
|
||||||
|
head -3 "$tmp/err"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
got=$("$tmp/prog" < "$input" 2>&1)
|
||||||
|
if [ "$got" == "$(cat "${f%.fn}.out")" ]; then
|
||||||
|
pass=$((pass + 1))
|
||||||
|
else
|
||||||
|
failed=$((failed + 1))
|
||||||
|
echo "FAIL (output) $f"
|
||||||
|
diff <(echo "$got") "${f%.fn}.out" | head -10
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "self-hosted compiler: $pass passed, $failed failed, $skipped skipped (features it doesn't have)"
|
||||||
|
[ $failed -eq 0 ]
|
||||||
Loading…
Reference in a new issue