diff --git a/README.md b/README.md index 6da36e8..04d6b4b 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ Development branch. Probably does not work. - [x] Benchmark! - [x] Abstract AST. - [x] Original go-toml testgen tests pass. -- [ ] Attach comments to AST (gated by parser flag). - [ ] Track file position (line, column) for errors. +- [ ] Attach comments to AST (gated by parser flag). - [ ] Benchmark again! ## Further work diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..80db004 --- /dev/null +++ b/errors.go @@ -0,0 +1,147 @@ +package toml + +import ( + "fmt" + "strconv" + "strings" + + "github.com/pelletier/go-toml/v2/internal/unsafe" +) + +// DecodeError represents an error encountered during the parsing or decoding +// of a TOML document. +// +// In addition to the error message, it contains the position in the document +// where it happened, as well as a human-readable representation that shows +// where the error occurred in the document. +type DecodeError struct { + message string + line int + column int + + human string +} + +// Error returns the error message contained in the DecodeError. +func (e *DecodeError) Error() string { + return e.message +} + +// String returns the human-readable contextualized error. This string is multi-line. +func (e *DecodeError) String() string { + return e.human +} + +/// Position returns the (line, column) pair indicating where the error +// occurred in the document. Positions are 1-indexed. +func (e *DecodeError) Position() (row int, column int) { + return e.line, e.column +} + +// decodeErrorFromHighlight creates a DecodeError referencing to a highlighted +// range of bytes from document. +// +// highlight needs to be a sub-slice of document, or this function panics. +// +// The function copies all bytes used in DecodeError, so that document and +// highlight can be freely deallocated. +func decodeErrorFromHighlight(document []byte, highlight []byte, message string) error { + err := &DecodeError{ + message: message, + } + + offset := unsafe.SubsliceOffset(document, highlight) + + err.line, err.column = positionAtEnd(document[:offset]) + before, after := linesOfContext(document, highlight, offset, 3) + + var buf strings.Builder + + maxLine := err.line + len(after) - 1 + lineColumnWidth := len(strconv.Itoa(maxLine)) + + for i := len(before) - 1; i > 0; i-- { + line := err.line - i + buf.WriteString(formatLineNumber(line, lineColumnWidth)) + buf.WriteString("| ") + buf.Write(before[i]) + buf.WriteRune('\n') + } + + buf.WriteString(formatLineNumber(err.line, lineColumnWidth)) + buf.WriteString("| ") + + if len(before) > 0 { + buf.Write(before[0]) + } + buf.Write(highlight) + if len(after) > 0 { + buf.Write(after[0]) + } + buf.WriteRune('\n') + buf.WriteString(strings.Repeat(" ", lineColumnWidth)) + buf.WriteString("| ") + if len(before) > 0 { + buf.WriteString(strings.Repeat(" ", len(before[0]))) + } + buf.WriteString(strings.Repeat("~", len(highlight))) + buf.WriteString(" ") + buf.WriteString(err.message) + + for i := 1; i < len(after); i++ { + buf.WriteRune('\n') + line := err.line + i + buf.WriteString(formatLineNumber(line, lineColumnWidth)) + buf.WriteString("| ") + buf.Write(after[i]) + } + + err.human = buf.String() + return err +} + +func formatLineNumber(line int, width int) string { + format := "%" + strconv.Itoa(width) + "d" + return fmt.Sprintf(format, line) +} + +func linesOfContext(document []byte, highlight []byte, offset int, linesAround int) ([][]byte, [][]byte) { + var beforeLines [][]byte + for beforeOffset, lastOffset := offset, offset; beforeOffset >= 0 && len(beforeLines) <= linesAround; beforeOffset-- { + if document[beforeOffset] == '\n' { + beforeLines = append(beforeLines, document[beforeOffset+1:lastOffset]) + lastOffset = beforeOffset + } else if beforeOffset == 0 && beforeOffset != lastOffset { + beforeLines = append(beforeLines, document[beforeOffset:lastOffset]) + } + } + + var afterLines [][]byte + + document = document[offset+len(highlight):] + for afterOffset, lastOffset := 0, 0; afterOffset < len(document) && len(afterLines) <= linesAround; afterOffset++ { + if document[afterOffset] == '\n' { + afterLines = append(afterLines, document[lastOffset:afterOffset]) + afterOffset++ // skip \n + lastOffset = afterOffset + } else if afterOffset == len(document)-1 && lastOffset != afterOffset+1 { + afterLines = append(afterLines, document[lastOffset:afterOffset+1]) + } + } + return beforeLines, afterLines +} + +func positionAtEnd(b []byte) (row int, column int) { + row = 1 + column = 1 + + for _, c := range b { + if c == '\n' { + row++ + column = 1 + } else { + column++ + } + } + return +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..9d0c5a9 --- /dev/null +++ b/errors_test.go @@ -0,0 +1,144 @@ +package toml + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDecodeError(t *testing.T) { + examples := []struct { + desc string + doc [3]string + msg string + expected string + }{ + { + desc: "no context", + doc: [3]string{"", "morning", ""}, + msg: "this is wrong", + expected: ` +1| morning + | ~~~~~~~ this is wrong`, + }, + { + desc: "one line", + doc: [3]string{"good ", "morning", " everyone"}, + msg: "this is wrong", + expected: ` +1| good morning everyone + | ~~~~~~~ this is wrong`, + }, + { + desc: "exactly 3 lines", + doc: [3]string{`line1 +line2 +line3 +before `, "highlighted", ` after +post line 1 +post line 2 +post line 3`}, + msg: "this is wrong", + expected: ` +1| line1 +2| line2 +3| line3 +4| before highlighted after + | ~~~~~~~~~~~ this is wrong +5| post line 1 +6| post line 2 +7| post line 3`, + }, + { + desc: "more than 3 lines", + doc: [3]string{`should not be seen1 +should not be seen2 +line1 +line2 +line3 +before `, "highlighted", ` after +post line 1 +post line 2 +post line 3 +should not be seen3 +should not be seen4`}, + msg: "this is wrong", + expected: ` +3| line1 +4| line2 +5| line3 +6| before highlighted after + | ~~~~~~~~~~~ this is wrong +7| post line 1 +8| post line 2 +9| post line 3`, + }, + { + desc: "more than 10 total lines", + doc: [3]string{`should not be seen 0 +should not be seen1 +should not be seen2 +should not be seen3 +line1 +line2 +line3 +before `, "highlighted", ` after +post line 1 +post line 2 +post line 3 +should not be seen3 +should not be seen4`}, + msg: "this is wrong", + expected: ` + 5| line1 + 6| line2 + 7| line3 + 8| before highlighted after + | ~~~~~~~~~~~ this is wrong + 9| post line 1 +10| post line 2 +11| post line 3`, + }, + { + desc: "last line of more than 10", + doc: [3]string{`should not be seen +should not be seen +should not be seen +should not be seen +should not be seen +should not be seen +should not be seen +line1 +line2 +line3 +before `, "highlighted", ``}, + msg: "this is wrong", + expected: ` + 8| line1 + 9| line2 +10| line3 +11| before highlighted + | ~~~~~~~~~~~ this is wrong +`, + }, + } + + for _, e := range examples { + t.Run(e.desc, func(t *testing.T) { + b := bytes.Buffer{} + b.Write([]byte(e.doc[0])) + start := b.Len() + b.Write([]byte(e.doc[1])) + end := b.Len() + b.Write([]byte(e.doc[2])) + doc := b.Bytes() + hl := doc[start:end] + + err := decodeErrorFromHighlight(doc, hl, e.msg) + derr := err.(*DecodeError) + assert.Equal(t, strings.Trim(e.expected, "\n"), derr.String()) + }) + } +} diff --git a/internal/errors/unsafe.go b/internal/unsafe/unsafe.go similarity index 86% rename from internal/errors/unsafe.go rename to internal/unsafe/unsafe.go index 626c01b..179fd1a 100644 --- a/internal/errors/unsafe.go +++ b/internal/unsafe/unsafe.go @@ -1,4 +1,4 @@ -package errors +package unsafe import ( "fmt" @@ -8,12 +8,10 @@ import ( const maxInt = uintptr(int(^uint(0) >> 1)) - -func UnsafeSubsliceOffset(data []byte, subslice []byte) int { +func SubsliceOffset(data []byte, subslice []byte) int { datap := (*reflect.SliceHeader)(unsafe.Pointer(&data)) hlp := (*reflect.SliceHeader)(unsafe.Pointer(&subslice)) - if hlp.Data < datap.Data { panic(fmt.Errorf("subslice address (%d) is before data address (%d)", hlp.Data, datap.Data)) } @@ -29,7 +27,7 @@ func UnsafeSubsliceOffset(data []byte, subslice []byte) int { panic(fmt.Errorf("slice offset (%d) is farther than data length (%d)", intoffset, datap.Len)) } - if intoffset + hlp.Len > datap.Len { + if intoffset+hlp.Len > datap.Len { panic(fmt.Errorf("slice ends (%d+%d) is farther than data length (%d)", intoffset, hlp.Len, datap.Len)) } diff --git a/internal/errors/unsafe_test.go b/internal/unsafe/unsafe_test.go similarity index 83% rename from internal/errors/unsafe_test.go rename to internal/unsafe/unsafe_test.go index 5c3b50b..a184be9 100644 --- a/internal/errors/unsafe_test.go +++ b/internal/unsafe/unsafe_test.go @@ -1,4 +1,4 @@ -package errors_test +package unsafe_test import ( "testing" @@ -6,13 +6,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/pelletier/go-toml/v2/internal/errors" + "github.com/pelletier/go-toml/v2/internal/unsafe" ) func TestUnsafeSubsliceOffsetValid(t *testing.T) { - examples := []struct{ - desc string - test func() ([]byte, []byte) + examples := []struct { + desc string + test func() ([]byte, []byte) offset int }{ { @@ -28,14 +28,14 @@ func TestUnsafeSubsliceOffsetValid(t *testing.T) { for _, e := range examples { t.Run(e.desc, func(t *testing.T) { d, s := e.test() - offset := errors.UnsafeSubsliceOffset(d, s) + offset := unsafe.SubsliceOffset(d, s) assert.Equal(t, e.offset, offset) }) } } func TestUnsafeSubsliceOffsetInvalid(t *testing.T) { - examples := []struct{ + examples := []struct { desc string test func() ([]byte, []byte) }{ @@ -72,7 +72,7 @@ func TestUnsafeSubsliceOffsetInvalid(t *testing.T) { t.Run(e.desc, func(t *testing.T) { d, s := e.test() require.Panics(t, func() { - errors.UnsafeSubsliceOffset(d, s) + unsafe.SubsliceOffset(d, s) }) }) }