diff --git a/src/build/Build.go b/src/build/Build.go index 0132eb4..fcf1698 100644 --- a/src/build/Build.go +++ b/src/build/Build.go @@ -7,34 +7,20 @@ import ( // Build describes a compiler build. type Build struct { - Files []string - WriteExecutable bool + Files []string } // New creates a new build. func New(files ...string) *Build { return &Build{ - Files: files, - WriteExecutable: true, + Files: files, } } // Run parses the input files and generates an executable file. -func (build *Build) Run() error { +func (build *Build) Run() (map[string]*Function, error) { functions, errors := Scan(build.Files) - allFunctions, err := Compile(functions, errors) - - if err != nil { - return err - } - - if !build.WriteExecutable { - return nil - } - - path := build.Executable() - code, data := Finalize(allFunctions) - return Write(path, code, data) + return Compile(functions, errors) } // Executable returns the path to the executable. diff --git a/src/build/Build_test.go b/src/build/Build_test.go index 7c91f0b..88d8e52 100644 --- a/src/build/Build_test.go +++ b/src/build/Build_test.go @@ -1,6 +1,7 @@ package build_test import ( + "path/filepath" "testing" "git.akyoto.dev/cli/q/src/build" @@ -9,16 +10,17 @@ import ( func TestBuild(t *testing.T) { b := build.New("../../examples/hello") - assert.Nil(t, b.Run()) + _, err := b.Run() + assert.Nil(t, err) } -func TestSkipExecutable(t *testing.T) { +func TestExecutable(t *testing.T) { b := build.New("../../examples/hello") - b.WriteExecutable = false - assert.Nil(t, b.Run()) + assert.Equal(t, filepath.Base(b.Executable()), "hello") } func TestNonExisting(t *testing.T) { b := build.New("does-not-exist") - assert.NotNil(t, b.Run()) + _, err := b.Run() + assert.NotNil(t, err) } diff --git a/src/build/Scan.go b/src/build/Scan.go index 86f161e..6c56628 100644 --- a/src/build/Scan.go +++ b/src/build/Scan.go @@ -8,6 +8,7 @@ import ( "git.akyoto.dev/cli/q/src/build/directory" "git.akyoto.dev/cli/q/src/build/token" + "git.akyoto.dev/cli/q/src/errors" ) // Scan scans the directory. @@ -86,46 +87,141 @@ func scanFile(path string, functions chan<- *Function) error { tokens := token.Tokenize(contents) var ( + i = 0 groupLevel = 0 blockLevel = 0 - headerStart = -1 + nameStart = -1 + paramsStart = -1 bodyStart = -1 ) - for i, t := range tokens { - switch t.Kind { - case token.Identifier: - if blockLevel == 0 && groupLevel == 0 { - headerStart = i + for { + // Function name + for i < len(tokens) { + if tokens[i].Kind == token.Identifier { + nameStart = i + i++ + break } - case token.GroupStart: - groupLevel++ - - case token.GroupEnd: - groupLevel-- - - case token.BlockStart: - blockLevel++ - - if blockLevel == 1 { - bodyStart = i + if tokens[i].Kind == token.NewLine { + i++ + continue } - case token.BlockEnd: - blockLevel-- + if tokens[i].Kind == token.EOF { + return nil + } - if blockLevel == 0 { - function := &Function{ - Name: tokens[headerStart].Text(), - Head: tokens[headerStart:bodyStart], - Body: tokens[bodyStart : i+1], + return errors.New(errors.ExpectedFunctionName, path, tokens, i) + } + + // Function parameters + for i < len(tokens) { + if tokens[i].Kind == token.GroupStart { + groupLevel++ + + if groupLevel == 1 { + paramsStart = i } - functions <- function + i++ + continue } - } - } - return nil + if tokens[i].Kind == token.GroupEnd { + groupLevel-- + + if groupLevel < 0 { + return errors.New(errors.MissingGroupStart, path, tokens, i) + } + + i++ + + if groupLevel == 0 { + break + } + + continue + } + + if tokens[i].Kind == token.EOF { + if groupLevel > 0 { + return errors.New(errors.MissingGroupEnd, path, tokens, i) + } + + if paramsStart == -1 { + return errors.New(errors.ExpectedFunctionParameters, path, tokens, i) + } + + return nil + } + + if groupLevel > 0 { + i++ + continue + } + + return errors.New(errors.ExpectedFunctionParameters, path, tokens, i) + } + + // Function definition + for i < len(tokens) { + if tokens[i].Kind == token.BlockStart { + blockLevel++ + + if blockLevel == 1 { + bodyStart = i + } + + i++ + continue + } + + if tokens[i].Kind == token.BlockEnd { + blockLevel-- + + if blockLevel < 0 { + return errors.New(errors.MissingBlockStart, path, tokens, i) + } + + i++ + + if blockLevel == 0 { + break + } + + continue + } + + if tokens[i].Kind == token.EOF { + if blockLevel > 0 { + return errors.New(errors.MissingBlockEnd, path, tokens, i) + } + + if bodyStart == -1 { + return errors.New(errors.ExpectedFunctionDefinition, path, tokens, i) + } + + return nil + } + + if blockLevel > 0 { + i++ + continue + } + + return errors.New(errors.ExpectedFunctionDefinition, path, tokens, i) + } + + functions <- &Function{ + Name: tokens[nameStart].Text(), + Head: tokens[paramsStart:bodyStart], + Body: tokens[bodyStart : i+1], + } + + nameStart = -1 + paramsStart = -1 + bodyStart = -1 + } } diff --git a/src/build/token/Kind.go b/src/build/token/Kind.go index 70065cd..b221442 100644 --- a/src/build/token/Kind.go +++ b/src/build/token/Kind.go @@ -7,6 +7,9 @@ const ( // Invalid represents an invalid token. Invalid Kind = iota + // EOF represents the end of file. + EOF + // NewLine represents the newline character. NewLine @@ -54,6 +57,7 @@ const ( func (kind Kind) String() string { return [...]string{ "Invalid", + "EOF", "NewLine", "Identifier", "Keyword", diff --git a/src/build/token/Token_test.go b/src/build/token/Token_test.go index 1bc125e..e1f66f5 100644 --- a/src/build/token/Token_test.go +++ b/src/build/token/Token_test.go @@ -35,6 +35,11 @@ func TestFunction(t *testing.T) { Bytes: []byte("}"), Position: 7, }, + { + Kind: token.EOF, + Bytes: nil, + Position: 8, + }, }) } @@ -51,6 +56,11 @@ func TestKeyword(t *testing.T) { Bytes: []byte("x"), Position: 7, }, + { + Kind: token.EOF, + Bytes: nil, + Position: 8, + }, }) } @@ -77,6 +87,11 @@ func TestArray(t *testing.T) { Bytes: []byte("]"), Position: 7, }, + { + Kind: token.EOF, + Bytes: nil, + Position: 8, + }, }) } @@ -93,6 +108,11 @@ func TestNewline(t *testing.T) { Bytes: []byte("\n"), Position: 1, }, + { + Kind: token.EOF, + Bytes: nil, + Position: 2, + }, }) } @@ -109,6 +129,11 @@ func TestNumber(t *testing.T) { Bytes: []byte("-456"), Position: 4, }, + { + Kind: token.EOF, + Bytes: nil, + Position: 8, + }, }) } @@ -140,6 +165,11 @@ func TestSeparator(t *testing.T) { Bytes: []byte("c"), Position: 4, }, + { + Kind: token.EOF, + Bytes: nil, + Position: 5, + }, }) } @@ -156,6 +186,11 @@ func TestString(t *testing.T) { Bytes: []byte(`"World"`), Position: 8, }, + { + Kind: token.EOF, + Bytes: nil, + Position: 15, + }, }) } @@ -167,6 +202,11 @@ func TestStringMultiline(t *testing.T) { Bytes: []byte("\"Hello\nWorld\""), Position: 0, }, + { + Kind: token.EOF, + Bytes: nil, + Position: 13, + }, }) } @@ -178,6 +218,11 @@ func TestStringEOF(t *testing.T) { Bytes: []byte(`"EOF`), Position: 0, }, + { + Kind: token.EOF, + Bytes: nil, + Position: 4, + }, }) } @@ -195,6 +240,7 @@ func TestTokenText(t *testing.T) { func TestTokenKind(t *testing.T) { assert.Equal(t, token.Invalid.String(), "Invalid") + assert.Equal(t, token.EOF.String(), "EOF") assert.Equal(t, token.NewLine.String(), "NewLine") assert.Equal(t, token.Identifier.String(), "Identifier") assert.Equal(t, token.Keyword.String(), "Keyword") diff --git a/src/build/token/Tokenize.go b/src/build/token/Tokenize.go index d0402e3..213adb6 100644 --- a/src/build/token/Tokenize.go +++ b/src/build/token/Tokenize.go @@ -31,6 +31,7 @@ func Tokenize(buffer []byte) List { for i < len(buffer) { if buffer[i] == '"' { end = i + 1 + i++ break } @@ -43,6 +44,8 @@ func Tokenize(buffer []byte) List { buffer[start:end], }) + continue + // Parentheses start case '(': tokens = append(tokens, Token{GroupStart, i, groupStartBytes}) @@ -121,6 +124,7 @@ func Tokenize(buffer []byte) List { i++ } + tokens = append(tokens, Token{EOF, i, nil}) return tokens } diff --git a/src/cli/Build.go b/src/cli/Build.go index 8f53e6a..697ec7e 100644 --- a/src/cli/Build.go +++ b/src/cli/Build.go @@ -11,11 +11,12 @@ import ( // Build builds an executable. func Build(args []string) int { b := build.New() + writeExecutable := true for i := 0; i < len(args); i++ { switch args[i] { case "--dry": - b.WriteExecutable = false + writeExecutable = false case "--verbose", "-v": config.Verbose = true @@ -34,7 +35,20 @@ func Build(args []string) int { b.Files = append(b.Files, ".") } - err := b.Run() + result, err := b.Run() + + if err != nil { + fmt.Println(err) + return 1 + } + + if !writeExecutable { + return 0 + } + + path := b.Executable() + code, data := build.Finalize(result) + err = build.Write(path, code, data) if err != nil { fmt.Println(err) diff --git a/src/cli/Help.go b/src/cli/Help.go index 6ca3b3c..ace2380 100644 --- a/src/cli/Help.go +++ b/src/cli/Help.go @@ -6,7 +6,7 @@ import "fmt" func Help(args []string) int { fmt.Println("Usage: q [command] [options]") fmt.Println("") - fmt.Println(" build [directory]") + fmt.Println(" build [directory] [file]") fmt.Println(" system") return 2 } diff --git a/src/cli/Main_test.go b/src/cli/Main_test.go index f5ae0a4..63b5366 100644 --- a/src/cli/Main_test.go +++ b/src/cli/Main_test.go @@ -1,8 +1,6 @@ package cli_test import ( - "fmt" - "os" "testing" "git.akyoto.dev/cli/q/src/cli" @@ -19,17 +17,16 @@ func TestCLI(t *testing.T) { {[]string{}, 2}, {[]string{"invalid"}, 2}, {[]string{"system"}, 0}, - {[]string{"build", "non-existing-directory"}, 1}, - {[]string{"build", "../../examples/hello/hello.q"}, 1}, + {[]string{"build", "invalid-directory"}, 1}, + {[]string{"build", "--invalid-parameter"}, 2}, {[]string{"build", "../../examples/hello", "--invalid"}, 2}, {[]string{"build", "../../examples/hello", "--dry"}, 0}, {[]string{"build", "../../examples/hello", "--dry", "--verbose"}, 0}, + {[]string{"build", "../../examples/hello/hello.q", "--dry"}, 0}, } for _, test := range tests { t.Log(test.arguments) - directory, _ := os.Getwd() - fmt.Println(directory) exitCode := cli.Main(test.arguments) assert.Equal(t, exitCode, test.expectedExitCode) } diff --git a/src/errors/Base.go b/src/errors/Base.go new file mode 100644 index 0000000..a019d58 --- /dev/null +++ b/src/errors/Base.go @@ -0,0 +1,11 @@ +package errors + +// Base is the base class for errors that have no parameters. +type Base struct { + Message string +} + +// Error generates the string representation. +func (err *Base) Error() string { + return err.Message +} diff --git a/src/errors/Error.go b/src/errors/Error.go new file mode 100644 index 0000000..c06bd20 --- /dev/null +++ b/src/errors/Error.go @@ -0,0 +1,67 @@ +package errors + +import ( + "fmt" + "os" + "path/filepath" + + "git.akyoto.dev/cli/q/src/build/token" +) + +// Error is a compiler error at a given line and column. +type Error struct { + Path string + Line int + Column int + Err error + Stack string +} + +// New generates an error message at the current token position. +// The error message is clickable in popular editors and leads you +// directly to the faulty file at the given line and position. +func New(err error, path string, tokens []token.Token, cursor int) *Error { + var ( + lineCount = 1 + lineStart = -1 + ) + + for i := range cursor { + if tokens[i].Kind == token.NewLine { + lineCount++ + lineStart = int(tokens[i].Position) + } + } + + var column int + + if cursor < len(tokens) { + column = tokens[cursor].Position - lineStart + } else { + lastToken := tokens[len(tokens)-1] + column = lastToken.Position - lineStart + len(lastToken.Text()) + } + + return &Error{path, lineCount, column, err, Stack()} +} + +// Error generates the string representation. +func (e *Error) Error() string { + path := e.Path + cwd, err := os.Getwd() + + if err == nil { + relativePath, err := filepath.Rel(cwd, e.Path) + + if err == nil { + path = relativePath + } + } + + return fmt.Sprintf("%s:%d:%d: %s\n\n%s", path, e.Line, e.Column, e.Err, e.Stack) +} + +// Unwrap returns the wrapped error. +func (e *Error) Unwrap() error { + return e.Err +} diff --git a/src/errors/Error_test.go b/src/errors/Error_test.go new file mode 100644 index 0000000..b518e1a --- /dev/null +++ b/src/errors/Error_test.go @@ -0,0 +1,32 @@ +package errors_test + +import ( + "path/filepath" + "strings" + "testing" + + "git.akyoto.dev/cli/q/src/build" + "git.akyoto.dev/cli/q/src/errors" + "git.akyoto.dev/go/assert" +) + +func TestErrors(t *testing.T) { + tests := []struct { + File string + ExpectedError error + }{ + {"ExpectedFunctionParameters.q", errors.ExpectedFunctionParameters}, + {"ExpectedFunctionDefinition.q", errors.ExpectedFunctionDefinition}, + } + + for _, test := range tests { + name := strings.TrimSuffix(test.File, ".q") + + t.Run(name, func(t *testing.T) { + b := build.New(filepath.Join("testdata", test.File)) + _, err := b.Run() + assert.NotNil(t, err) + assert.Contains(t, err.Error(), test.ExpectedError.Error()) + }) + } +} diff --git a/src/errors/ScanErrors.go b/src/errors/ScanErrors.go new file mode 100644 index 0000000..7fe07f2 --- /dev/null +++ b/src/errors/ScanErrors.go @@ -0,0 +1,11 @@ +package errors + +var ( + MissingBlockStart = &Base{"Missing '{'"} + MissingBlockEnd = &Base{"Missing '}'"} + MissingGroupStart = &Base{"Missing '('"} + MissingGroupEnd = &Base{"Missing ')'"} + ExpectedFunctionName = &Base{"Expected function name"} + ExpectedFunctionParameters = &Base{"Expected function parameters"} + ExpectedFunctionDefinition = &Base{"Expected function definition"} +) diff --git a/src/errors/Stack.go b/src/errors/Stack.go new file mode 100644 index 0000000..90a26ae --- /dev/null +++ b/src/errors/Stack.go @@ -0,0 +1,30 @@ +package errors + +import ( + "runtime" + "strings" +) + +// Stack generates a stack trace. +func Stack() string { + var ( + final []string + buffer = make([]byte, 4096) + n = runtime.Stack(buffer, false) + stack = string(buffer[:n]) + lines = strings.Split(stack, "\n") + ) + + for i := 6; i < len(lines); i += 2 { + line := strings.TrimSpace(lines[i]) + space := strings.LastIndex(line, " ") + + if space != -1 { + line = line[:space] + } + + final = append(final, line) + } + + return strings.Join(final, "\n") +} diff --git a/src/errors/testdata/ExpectedFunctionDefinition.q b/src/errors/testdata/ExpectedFunctionDefinition.q new file mode 100644 index 0000000..ac72fa7 --- /dev/null +++ b/src/errors/testdata/ExpectedFunctionDefinition.q @@ -0,0 +1 @@ +main() \ No newline at end of file diff --git a/src/errors/testdata/ExpectedFunctionParameters.q b/src/errors/testdata/ExpectedFunctionParameters.q new file mode 100644 index 0000000..88d050b --- /dev/null +++ b/src/errors/testdata/ExpectedFunctionParameters.q @@ -0,0 +1 @@ +main \ No newline at end of file