Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
module go.followtheprocess.codes/zap

go 1.25

require go.followtheprocess.codes/test v0.23.0

require (
go.followtheprocess.codes/hue v0.6.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/term v0.34.0 // indirect
)
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
go.followtheprocess.codes/hue v0.6.0 h1:JDLnRrkauCCIyYRqKNBDM+X6X5o75j2CG3iddnzIuhc=
go.followtheprocess.codes/hue v0.6.0/go.mod h1:tNCWKaywHqkFo20hYOVwG7CaoRajJeE2AueP5HStY7U=
go.followtheprocess.codes/snapshot v0.6.0 h1:aq7WIc8hInqdpdrOzntk9lqHwxUqSw3YbgLYaoy0laQ=
go.followtheprocess.codes/snapshot v0.6.0/go.mod h1:0hskrLbmTgcv3h1YgVgX0CXiiOKq0UvhM4PewnOZOno=
go.followtheprocess.codes/test v0.23.0 h1:XpKkEAzzm2/FmOAfR6GHRvgdY3XhragOWYCCvr9F6wg=
go.followtheprocess.codes/test v0.23.0/go.mod h1:Bhx7T8XRQs6w7DuT06uG4EPbGeaVgj3HL4laIGLVV4w=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
70 changes: 70 additions & 0 deletions internal/syntax/syntax.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Package syntax handles parsing the raw .http file text into meaningful
// data structures and implements the tokeniser and parser as well as some
// language level integration tests.
package syntax

import "fmt"

// An ErrorHandler may be provided to parts of the parsing pipeline. If a syntax error is encountered and
// a non-nil handler was provided, it is called with the position info and error message.
type ErrorHandler func(pos Position, msg string)

// Position is an arbitrary source file position including file, line
// and column information. It can also express a range of source via StartCol
// and EndCol, this is useful for error reporting.
//
// Positions without filenames are considered invalid, in the case of stdin
// the string "stdin" may be used.
type Position struct {
Name string // Filename
Offset int // Byte offset of the position from the start of the file
Line int // Line number (1 indexed)
StartCol int // Start column (1 indexed)
EndCol int // End column (1 indexed), EndCol == StartCol when pointing to a single character
}

// IsValid reports whether the [Position] describes a valid source position.
//
// The rules are:
//
// - At least Name, Line and StartCol must be set (and non zero)
// - EndCol cannot be 0, it's only allowed values are StartCol or any number greater than StartCol
func (p Position) IsValid() bool {
if p.Name == "" || p.Line < 1 || p.StartCol < 1 || p.EndCol < 1 ||
(p.EndCol >= 1 && p.EndCol < p.StartCol) {
return false
}

return true
}

// String returns a string representation of a [Position].
//
// It is formatted such that most text editors/terminals will be able to support clicking on it
// and navigating to the position.
//
// Depending on which fields are set, the string returned will be different:
//
// - "file:line:start-end": valid position pointing to a range of text on the line
// - "file:line:start": valid position pointing to a single character on the line (EndCol == StartCol)
//
// At least Name, Line and StartCol must be present for a valid position, and Line and StarCol must be > 0.
// If not, an error string will be returned.
func (p Position) String() string {
if !p.IsValid() {
return fmt.Sprintf(
"BadPosition: {Name: %q, Line: %d, StartCol: %d, EndCol: %d}",
p.Name,
p.Line,
p.StartCol,
p.EndCol,
)
}

if p.StartCol == p.EndCol {
// No range, just a single position
return fmt.Sprintf("%s:%d:%d", p.Name, p.Line, p.StartCol)
}

return fmt.Sprintf("%s:%d:%d-%d", p.Name, p.Line, p.StartCol, p.EndCol)
}
140 changes: 140 additions & 0 deletions internal/syntax/syntax_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package syntax_test

import (
"fmt"
"testing"

"go.followtheprocess.codes/test"
"go.followtheprocess.codes/zap/internal/syntax"
)

func TestPositionString(t *testing.T) {
tests := []struct {
name string // Name of the test case
want string // Expected return value
pos syntax.Position // Position under test
}{
{
name: "empty",
pos: syntax.Position{},
want: `BadPosition: {Name: "", Line: 0, StartCol: 0, EndCol: 0}`,
},
{
name: "missing name",
pos: syntax.Position{Line: 12, StartCol: 2, EndCol: 6},
want: `BadPosition: {Name: "", Line: 12, StartCol: 2, EndCol: 6}`,
},
{
name: "zero line",
pos: syntax.Position{Name: "file.txt", Line: 0, StartCol: 12, EndCol: 19},
want: `BadPosition: {Name: "file.txt", Line: 0, StartCol: 12, EndCol: 19}`,
},
{
name: "zero start column",
pos: syntax.Position{Name: "file.txt", Line: 4, StartCol: 0, EndCol: 19},
want: `BadPosition: {Name: "file.txt", Line: 4, StartCol: 0, EndCol: 19}`,
},
{
name: "zero end column",
pos: syntax.Position{Name: "file.txt", Line: 4, StartCol: 1, EndCol: 0},
want: `BadPosition: {Name: "file.txt", Line: 4, StartCol: 1, EndCol: 0}`,
},
{
name: "end less than start",
pos: syntax.Position{Name: "test.http", Line: 1, StartCol: 6, EndCol: 4},
want: `BadPosition: {Name: "test.http", Line: 1, StartCol: 6, EndCol: 4}`,
},
{
name: "valid single column",
pos: syntax.Position{Name: "demo.http", Line: 1, StartCol: 6, EndCol: 6},
want: "demo.http:1:6",
},
{
name: "valid column range",
pos: syntax.Position{Name: "demo.http", Line: 17, StartCol: 20, EndCol: 26},
want: "demo.http:17:20-26",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
test.Equal(t, tt.pos.String(), tt.want)
})
}
}

func FuzzPosition(f *testing.F) {
f.Add("", 0, 0, 0)
f.Add("name.txt", 1, 1, 2)
f.Add("valid.http", 12, 17, 19)
f.Add("invalid.http", 0, -9, 9999)

f.Fuzz(func(t *testing.T, name string, line, startCol, endCol int) {
pos := syntax.Position{
Name: name,
Line: line,
StartCol: startCol,
EndCol: endCol,
}

got := pos.String()

// Property: If IsValid returns false, the string must be this format
if !pos.IsValid() {
want := fmt.Sprintf(
"BadPosition: {Name: %q, Line: %d, StartCol: %d, EndCol: %d}",
name,
line,
startCol,
endCol,
)
test.Equal(t, got, want)

return
}

// Property: If IsValid returned true, Line must be >= 1
test.True(
t,
pos.Line >= 1,
test.Context("IsValid() = true but pos.Line (%d) was not >= 1", pos.Line),
)

// Property: If IsValid returned true, StartCol must be >= 1
test.True(
t,
pos.StartCol >= 1,
test.Context("IsValid() = true but pos.StartCol (%d) was not >= 1", pos.StartCol),
)

// Property: If IsValid returned true, EndCol must be >= 1
test.True(
t,
pos.EndCol >= 1,
test.Context("IsValid() = true but pos.EndCol (%d) was not >= 1", pos.EndCol),
)

// Property: If IsValid returned true, EndCol must also be >= StartCol
test.True(
t,
pos.EndCol >= pos.StartCol,
test.Context(
"IsValid() = true but pos.EndCol (%d) was not >= pos.StartCol (%d)",
pos.EndCol,
pos.StartCol,
),
)

// Property: If StartCol == EndCol, no range must appear in the string
if startCol == endCol {
want := fmt.Sprintf("%s:%d:%d", name, line, startCol)
test.Equal(t, got, want)

return
}

// Otherwise the position must be a valid position with a column range
want := fmt.Sprintf("%s:%d:%d-%d", name, line, startCol, endCol)
test.Equal(t, got, want)
})
}
Loading