Implemented error messages

This commit is contained in:
Eduard Urbach 2024-06-13 12:13:32 +02:00
parent 2d990b0bee
commit 9458253f31
Signed by: akyoto
GPG Key ID: C874F672B1AF20C0
16 changed files with 362 additions and 60 deletions

View File

@ -8,33 +8,19 @@ import (
// Build describes a compiler build. // Build describes a compiler build.
type Build struct { type Build struct {
Files []string Files []string
WriteExecutable bool
} }
// New creates a new build. // New creates a new build.
func New(files ...string) *Build { func New(files ...string) *Build {
return &Build{ return &Build{
Files: files, Files: files,
WriteExecutable: true,
} }
} }
// Run parses the input files and generates an executable file. // 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) functions, errors := Scan(build.Files)
allFunctions, err := Compile(functions, errors) return 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)
} }
// Executable returns the path to the executable. // Executable returns the path to the executable.

View File

@ -1,6 +1,7 @@
package build_test package build_test
import ( import (
"path/filepath"
"testing" "testing"
"git.akyoto.dev/cli/q/src/build" "git.akyoto.dev/cli/q/src/build"
@ -9,16 +10,17 @@ import (
func TestBuild(t *testing.T) { func TestBuild(t *testing.T) {
b := build.New("../../examples/hello") 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 := build.New("../../examples/hello")
b.WriteExecutable = false assert.Equal(t, filepath.Base(b.Executable()), "hello")
assert.Nil(t, b.Run())
} }
func TestNonExisting(t *testing.T) { func TestNonExisting(t *testing.T) {
b := build.New("does-not-exist") b := build.New("does-not-exist")
assert.NotNil(t, b.Run()) _, err := b.Run()
assert.NotNil(t, err)
} }

View File

@ -8,6 +8,7 @@ import (
"git.akyoto.dev/cli/q/src/build/directory" "git.akyoto.dev/cli/q/src/build/directory"
"git.akyoto.dev/cli/q/src/build/token" "git.akyoto.dev/cli/q/src/build/token"
"git.akyoto.dev/cli/q/src/errors"
) )
// Scan scans the directory. // Scan scans the directory.
@ -86,46 +87,141 @@ func scanFile(path string, functions chan<- *Function) error {
tokens := token.Tokenize(contents) tokens := token.Tokenize(contents)
var ( var (
i = 0
groupLevel = 0 groupLevel = 0
blockLevel = 0 blockLevel = 0
headerStart = -1 nameStart = -1
paramsStart = -1
bodyStart = -1 bodyStart = -1
) )
for i, t := range tokens { for {
switch t.Kind { // Function name
case token.Identifier: for i < len(tokens) {
if blockLevel == 0 && groupLevel == 0 { if tokens[i].Kind == token.Identifier {
headerStart = i nameStart = i
i++
break
} }
case token.GroupStart: if tokens[i].Kind == token.NewLine {
i++
continue
}
if tokens[i].Kind == token.EOF {
return nil
}
return errors.New(errors.ExpectedFunctionName, path, tokens, i)
}
// Function parameters
for i < len(tokens) {
if tokens[i].Kind == token.GroupStart {
groupLevel++ groupLevel++
case token.GroupEnd: if groupLevel == 1 {
paramsStart = i
}
i++
continue
}
if tokens[i].Kind == token.GroupEnd {
groupLevel-- groupLevel--
case token.BlockStart: 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++ blockLevel++
if blockLevel == 1 { if blockLevel == 1 {
bodyStart = i bodyStart = i
} }
case token.BlockEnd: i++
continue
}
if tokens[i].Kind == token.BlockEnd {
blockLevel-- blockLevel--
if blockLevel == 0 { if blockLevel < 0 {
function := &Function{ return errors.New(errors.MissingBlockStart, path, tokens, i)
Name: tokens[headerStart].Text(),
Head: tokens[headerStart:bodyStart],
Body: tokens[bodyStart : i+1],
} }
functions <- function 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 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
}
}

View File

@ -7,6 +7,9 @@ const (
// Invalid represents an invalid token. // Invalid represents an invalid token.
Invalid Kind = iota Invalid Kind = iota
// EOF represents the end of file.
EOF
// NewLine represents the newline character. // NewLine represents the newline character.
NewLine NewLine
@ -54,6 +57,7 @@ const (
func (kind Kind) String() string { func (kind Kind) String() string {
return [...]string{ return [...]string{
"Invalid", "Invalid",
"EOF",
"NewLine", "NewLine",
"Identifier", "Identifier",
"Keyword", "Keyword",

View File

@ -35,6 +35,11 @@ func TestFunction(t *testing.T) {
Bytes: []byte("}"), Bytes: []byte("}"),
Position: 7, Position: 7,
}, },
{
Kind: token.EOF,
Bytes: nil,
Position: 8,
},
}) })
} }
@ -51,6 +56,11 @@ func TestKeyword(t *testing.T) {
Bytes: []byte("x"), Bytes: []byte("x"),
Position: 7, Position: 7,
}, },
{
Kind: token.EOF,
Bytes: nil,
Position: 8,
},
}) })
} }
@ -77,6 +87,11 @@ func TestArray(t *testing.T) {
Bytes: []byte("]"), Bytes: []byte("]"),
Position: 7, Position: 7,
}, },
{
Kind: token.EOF,
Bytes: nil,
Position: 8,
},
}) })
} }
@ -93,6 +108,11 @@ func TestNewline(t *testing.T) {
Bytes: []byte("\n"), Bytes: []byte("\n"),
Position: 1, Position: 1,
}, },
{
Kind: token.EOF,
Bytes: nil,
Position: 2,
},
}) })
} }
@ -109,6 +129,11 @@ func TestNumber(t *testing.T) {
Bytes: []byte("-456"), Bytes: []byte("-456"),
Position: 4, Position: 4,
}, },
{
Kind: token.EOF,
Bytes: nil,
Position: 8,
},
}) })
} }
@ -140,6 +165,11 @@ func TestSeparator(t *testing.T) {
Bytes: []byte("c"), Bytes: []byte("c"),
Position: 4, Position: 4,
}, },
{
Kind: token.EOF,
Bytes: nil,
Position: 5,
},
}) })
} }
@ -156,6 +186,11 @@ func TestString(t *testing.T) {
Bytes: []byte(`"World"`), Bytes: []byte(`"World"`),
Position: 8, Position: 8,
}, },
{
Kind: token.EOF,
Bytes: nil,
Position: 15,
},
}) })
} }
@ -167,6 +202,11 @@ func TestStringMultiline(t *testing.T) {
Bytes: []byte("\"Hello\nWorld\""), Bytes: []byte("\"Hello\nWorld\""),
Position: 0, Position: 0,
}, },
{
Kind: token.EOF,
Bytes: nil,
Position: 13,
},
}) })
} }
@ -178,6 +218,11 @@ func TestStringEOF(t *testing.T) {
Bytes: []byte(`"EOF`), Bytes: []byte(`"EOF`),
Position: 0, Position: 0,
}, },
{
Kind: token.EOF,
Bytes: nil,
Position: 4,
},
}) })
} }
@ -195,6 +240,7 @@ func TestTokenText(t *testing.T) {
func TestTokenKind(t *testing.T) { func TestTokenKind(t *testing.T) {
assert.Equal(t, token.Invalid.String(), "Invalid") 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.NewLine.String(), "NewLine")
assert.Equal(t, token.Identifier.String(), "Identifier") assert.Equal(t, token.Identifier.String(), "Identifier")
assert.Equal(t, token.Keyword.String(), "Keyword") assert.Equal(t, token.Keyword.String(), "Keyword")

View File

@ -31,6 +31,7 @@ func Tokenize(buffer []byte) List {
for i < len(buffer) { for i < len(buffer) {
if buffer[i] == '"' { if buffer[i] == '"' {
end = i + 1 end = i + 1
i++
break break
} }
@ -43,6 +44,8 @@ func Tokenize(buffer []byte) List {
buffer[start:end], buffer[start:end],
}) })
continue
// Parentheses start // Parentheses start
case '(': case '(':
tokens = append(tokens, Token{GroupStart, i, groupStartBytes}) tokens = append(tokens, Token{GroupStart, i, groupStartBytes})
@ -121,6 +124,7 @@ func Tokenize(buffer []byte) List {
i++ i++
} }
tokens = append(tokens, Token{EOF, i, nil})
return tokens return tokens
} }

View File

@ -11,11 +11,12 @@ import (
// Build builds an executable. // Build builds an executable.
func Build(args []string) int { func Build(args []string) int {
b := build.New() b := build.New()
writeExecutable := true
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
switch args[i] { switch args[i] {
case "--dry": case "--dry":
b.WriteExecutable = false writeExecutable = false
case "--verbose", "-v": case "--verbose", "-v":
config.Verbose = true config.Verbose = true
@ -34,7 +35,20 @@ func Build(args []string) int {
b.Files = append(b.Files, ".") 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 { if err != nil {
fmt.Println(err) fmt.Println(err)

View File

@ -6,7 +6,7 @@ import "fmt"
func Help(args []string) int { func Help(args []string) int {
fmt.Println("Usage: q [command] [options]") fmt.Println("Usage: q [command] [options]")
fmt.Println("") fmt.Println("")
fmt.Println(" build [directory]") fmt.Println(" build [directory] [file]")
fmt.Println(" system") fmt.Println(" system")
return 2 return 2
} }

View File

@ -1,8 +1,6 @@
package cli_test package cli_test
import ( import (
"fmt"
"os"
"testing" "testing"
"git.akyoto.dev/cli/q/src/cli" "git.akyoto.dev/cli/q/src/cli"
@ -19,17 +17,16 @@ func TestCLI(t *testing.T) {
{[]string{}, 2}, {[]string{}, 2},
{[]string{"invalid"}, 2}, {[]string{"invalid"}, 2},
{[]string{"system"}, 0}, {[]string{"system"}, 0},
{[]string{"build", "non-existing-directory"}, 1}, {[]string{"build", "invalid-directory"}, 1},
{[]string{"build", "../../examples/hello/hello.q"}, 1}, {[]string{"build", "--invalid-parameter"}, 2},
{[]string{"build", "../../examples/hello", "--invalid"}, 2}, {[]string{"build", "../../examples/hello", "--invalid"}, 2},
{[]string{"build", "../../examples/hello", "--dry"}, 0}, {[]string{"build", "../../examples/hello", "--dry"}, 0},
{[]string{"build", "../../examples/hello", "--dry", "--verbose"}, 0}, {[]string{"build", "../../examples/hello", "--dry", "--verbose"}, 0},
{[]string{"build", "../../examples/hello/hello.q", "--dry"}, 0},
} }
for _, test := range tests { for _, test := range tests {
t.Log(test.arguments) t.Log(test.arguments)
directory, _ := os.Getwd()
fmt.Println(directory)
exitCode := cli.Main(test.arguments) exitCode := cli.Main(test.arguments)
assert.Equal(t, exitCode, test.expectedExitCode) assert.Equal(t, exitCode, test.expectedExitCode)
} }

11
src/errors/Base.go Normal file
View File

@ -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
}

67
src/errors/Error.go Normal file
View File

@ -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
}

32
src/errors/Error_test.go Normal file
View File

@ -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())
})
}
}

11
src/errors/ScanErrors.go Normal file
View File

@ -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"}
)

30
src/errors/Stack.go Normal file
View File

@ -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")
}

View File

@ -0,0 +1 @@
main()

View File

@ -0,0 +1 @@
main