refactor(perfdata): split the range parser into many functions

This commit is contained in:
Emmanuel BENOîT 2024-07-20 22:21:52 +02:00
parent 2b58a27cc0
commit 402f7f3c11
Signed by: Emmanuel BENOîT
SSH key fingerprint: SHA256:l7PFUUF5TCDsvYeQC9OnTNz08dFY7Fvf4Hv3neIqYpg
4 changed files with 262 additions and 177 deletions

View file

@ -3,7 +3,6 @@ package perfdata // import nocternity.net/gomonop/pkg/perfdata
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
) )
// Performance data range. // Performance data range.
@ -92,121 +91,3 @@ func (r *Range) Contains(value float64) bool {
return (inStart || inEnd) != r.inside return (inStart || inEnd) != r.inside
} }
// A state of the range parser.
type rangeParserState int
const (
rpsInit rangeParserState = iota // Initial state
rpsExpectStart // Expect the start of the range
rpsInStart // Reading the start of the range
rpsExpectColon // Expect the colon that separates the start and end
rpsExpectEnd // Expect the end of the range
rpsInEnd // Reading the end of the range
)
// An error emitted by the range parser.
type rangeParserError struct {
input string
position int
message string
}
// parseError creates a new range parser error.
func parseError(input string, position int, message string) *rangeParserError {
return &rangeParserError{
input: input,
position: position,
message: message,
}
}
// Error implements the error interface for the range parser error type.
func (rpe *rangeParserError) Error() string {
return fmt.Sprintf("in `%s' at position %d: %s", rpe.input, rpe.position, rpe.message)
}
// Try to parse a string into a performance data range.
func ParseRange(input string) (*Range, error) {
runes := []rune(input)
index := 0
state := rpsInit
parsed := Range{}
startOfStart := 0
startOfEnd := 0
strBuilder := strings.Builder{}
for index < len(runes) {
curRune := runes[index]
switch state {
case rpsInit:
if curRune == '@' {
parsed.inside = true
index++
}
state = rpsExpectStart
case rpsExpectStart:
switch curRune {
case ':':
parsed.start = "0"
state = rpsExpectEnd
case '~':
state = rpsExpectColon
default:
strBuilder.WriteRune(curRune)
startOfStart = index
state = rpsInStart
}
index++
case rpsInStart:
switch curRune {
case ':':
parsed.start = strBuilder.String()
if !valueCheck.MatchString(parsed.start) {
return nil, parseError(input, startOfStart, "invalid start value")
}
strBuilder.Reset()
state = rpsExpectEnd
default:
strBuilder.WriteRune(curRune)
}
index++
case rpsExpectColon:
switch curRune {
case ':':
state = rpsExpectEnd
index++
default:
return nil, parseError(input, index, "expected ':'")
}
case rpsExpectEnd:
startOfEnd = index
state = rpsInEnd
case rpsInEnd:
strBuilder.WriteRune(curRune)
index++
}
}
if state == rpsInStart {
// The range was a single value, so that's the upper bound.
parsed.start = "0"
state = rpsInEnd
}
if state != rpsInEnd {
return nil, parseError(input, index, "unexpected end of input")
}
parsed.end = strBuilder.String()
if !valueCheck.MatchString(parsed.end) {
return nil, parseError(input, startOfEnd, "invalid end value")
}
return &parsed, nil
}

View file

@ -2,11 +2,9 @@ package perfdata // import nocternity.net/gomonop/pkg/perfdata
import ( import (
"fmt" "fmt"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestRangeMaxInvalid(t *testing.T) { func TestRangeMaxInvalid(t *testing.T) {
@ -126,59 +124,3 @@ func TestRangeContains(t *testing.T) {
}) })
} }
} }
func TestRangeParserOk(t *testing.T) {
type Test struct {
in string
out Range
}
tests := []Test{
{in: ":0", out: Range{start: "0", end: "0"}},
{in: "0:0", out: Range{start: "0", end: "0"}},
{in: "~:0", out: Range{start: "", end: "0"}},
{in: ":123", out: Range{start: "0", end: "123"}},
{in: "0:123", out: Range{start: "0", end: "123"}},
{in: "~:123", out: Range{start: "", end: "123"}},
{in: "1", out: Range{start: "0", end: "1"}},
{in: "@:0", out: Range{start: "0", end: "0", inside: true}},
{in: "@0:0", out: Range{start: "0", end: "0", inside: true}},
{in: "@~:0", out: Range{start: "", end: "0", inside: true}},
{in: "@:123", out: Range{start: "0", end: "123", inside: true}},
{in: "@0:123", out: Range{start: "0", end: "123", inside: true}},
{in: "@~:123", out: Range{start: "", end: "123", inside: true}},
{in: "@1", out: Range{start: "0", end: "1", inside: true}},
}
for _, test := range tests {
result, err := ParseRange(test.in)
require.NoError(t, err, "Expected no error, got '%v'", err)
assert.Equal(t, test.out, *result, "Expected '%v', got '%v'", test.out, *result)
}
}
func TestRangeParserError(t *testing.T) {
type Test struct {
in string
errPos int
}
tests := []Test{
{in: "", errPos: 0},
{in: ":", errPos: 1},
{in: "x:1", errPos: 0},
{in: ":~", errPos: 1},
{in: "@", errPos: 1},
{in: "@:", errPos: 2},
{in: "@x:1", errPos: 1},
{in: "@:~", errPos: 2},
}
for _, test := range tests {
result, err := ParseRange(test.in)
require.Error(t, err, "Expected error, got '%v'", err)
assert.Nil(t, result, "Expected nil result, got '%v'", result)
assert.True(
t, strings.Contains(err.Error(), fmt.Sprintf("at position %d", test.errPos)),
"Expected error to contain '%s', got '%s'", fmt.Sprintf("at position %d", test.errPos), err,
)
}
}

196
pkg/perfdata/rangeparser.go Normal file
View file

@ -0,0 +1,196 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
import (
"fmt"
"strings"
)
// A state of the range parser.
type rangeParserState int
const (
rpsInit rangeParserState = iota // Initial state
rpsExpectStart // Expect the start of the range
rpsInStart // Reading the start of the range
rpsExpectColon // Expect the colon that separates the start and end
rpsExpectEnd // Expect the end of the range
rpsInEnd // Reading the end of the range
)
// An error emitted by the range parser.
type rangeParserError struct {
input string
position int
message string
}
// parseError creates a new range parser error.
func parseError(input string, position int, message string) *rangeParserError {
return &rangeParserError{
input: input,
position: position,
message: message,
}
}
// Error implements the error interface for the range parser error type.
func (rpe *rangeParserError) Error() string {
return fmt.Sprintf("in `%s' at position %d: %s", rpe.input, rpe.position, rpe.message)
}
// The full state of the range parser.
type rangeParser struct {
runes []rune // The runes being parsed
index int // The read position
state rangeParserState // The FSM state
startOfValue int // The position of the start of the value being read
strBuilder strings.Builder // An accumulator for values
output Range // The output being generated
}
// initRangeParser initializes the range parser from the specified input.
func initRangeParser(input string) *rangeParser {
return &rangeParser{
runes: []rune(input),
state: rpsInit,
}
}
// handleInit handles the parser's rpsInit state. It can accept a '@' or skip
// to rpsExpectStart.
func (rp *rangeParser) handleInit(current rune) {
if current == '@' {
rp.output.inside = true
rp.index++
}
rp.state = rpsExpectStart
}
// handleExpectStart handles the parser's rpsExpectStart state. It may accept
// a colon, which will move the parser to the rpsExpectEnd state, a '~' which
// will move it to rpsExpectColon, or any other rune which will cause the
// accumulation of the "start" value to begin, and the state to be moved to
// handleInStart.
func (rp *rangeParser) handleExpectStart(current rune) {
switch current {
case ':':
rp.output.start = "0"
rp.state = rpsExpectEnd
rp.index++
case '~':
rp.state = rpsExpectColon
rp.index++
default:
rp.startOfValue = rp.index
rp.state = rpsInStart
}
}
// handleInStart handles the parser's rpsInStart state, which corresponds to the
// reading of the "start" value. If a colon is found, the value will be written
// to the range structure and validated, before switching to the rpsExpectEnd
// state. Otherwise it will simply accumulate runes.
func (rp *rangeParser) handleInStart(current rune) error {
switch current {
case ':':
rp.output.start = rp.strBuilder.String()
if !valueCheck.MatchString(rp.output.start) {
return parseError(string(rp.runes), rp.startOfValue, "invalid start value")
}
rp.strBuilder.Reset()
rp.state = rpsExpectEnd
default:
rp.strBuilder.WriteRune(current)
}
rp.index++
return nil
}
// handleExpectColon handles the parser's rpsExpectColon state. A colon MUST be
// read, otherwise an error will be returned. The parser will then switch to the
// rpsExpectEnd state.
func (rp *rangeParser) handleExpectColon(current rune) error {
switch current {
case ':':
rp.state = rpsExpectEnd
rp.index++
default:
return parseError(string(rp.runes), rp.index, "expected ':'")
}
return nil
}
// handleExpectEnd handles the parser's rpsExpectEnd state, which sets the
// position of the beginning of the "end" value, and jumps to the rpsInEnd
// state.
func (rp *rangeParser) handleExpectEnd() {
rp.startOfValue = rp.index
rp.state = rpsInEnd
}
// handleInEnd handles the parser's rpsInEnd state, which accumulates runes for
// the "end" value.
func (rp *rangeParser) handleInEnd(current rune) {
rp.strBuilder.WriteRune(current)
rp.index++
}
// consumeInput is the parser's state machine.
func (rp *rangeParser) consumeInput() error {
for rp.index < len(rp.runes) {
var err error
curRune := rp.runes[rp.index]
switch rp.state {
case rpsInit:
rp.handleInit(curRune)
case rpsExpectStart:
rp.handleExpectStart(curRune)
case rpsInStart:
err = rp.handleInStart(curRune)
case rpsExpectColon:
err = rp.handleExpectColon(curRune)
case rpsExpectEnd:
rp.handleExpectEnd()
case rpsInEnd:
rp.handleInEnd(curRune)
}
if err != nil {
return err
}
}
return nil
}
// Try to parse a string into a performance data range.
func ParseRange(input string) (*Range, error) {
parser := initRangeParser(input)
if err := parser.consumeInput(); err != nil {
return nil, err
}
if parser.state == rpsInStart {
// The range was a single value, so that's the upper bound.
parser.output.start = "0"
parser.state = rpsInEnd
}
if parser.state != rpsInEnd {
return nil, parseError(input, parser.index, "unexpected end of input")
}
parser.output.end = parser.strBuilder.String()
if !valueCheck.MatchString(parser.output.end) {
return nil, parseError(input, parser.startOfValue, "invalid end value")
}
return &parser.output, nil
}

View file

@ -0,0 +1,66 @@
package perfdata // import nocternity.net/gomonop/pkg/perfdata
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRangeParserOk(t *testing.T) {
type Test struct {
in string
out Range
}
tests := []Test{
{in: ":0", out: Range{start: "0", end: "0"}},
{in: "0:0", out: Range{start: "0", end: "0"}},
{in: "~:0", out: Range{start: "", end: "0"}},
{in: ":123", out: Range{start: "0", end: "123"}},
{in: "0:123", out: Range{start: "0", end: "123"}},
{in: "~:123", out: Range{start: "", end: "123"}},
{in: "1", out: Range{start: "0", end: "1"}},
{in: "@:0", out: Range{start: "0", end: "0", inside: true}},
{in: "@0:0", out: Range{start: "0", end: "0", inside: true}},
{in: "@~:0", out: Range{start: "", end: "0", inside: true}},
{in: "@:123", out: Range{start: "0", end: "123", inside: true}},
{in: "@0:123", out: Range{start: "0", end: "123", inside: true}},
{in: "@~:123", out: Range{start: "", end: "123", inside: true}},
{in: "@1", out: Range{start: "0", end: "1", inside: true}},
}
for _, test := range tests {
result, err := ParseRange(test.in)
require.NoError(t, err, "Expected no error, got '%v'", err)
assert.Equal(t, test.out, *result, "Expected '%v', got '%v'", test.out, *result)
}
}
func TestRangeParserError(t *testing.T) {
type Test struct {
in string
errPos int
}
tests := []Test{
{in: "", errPos: 0},
{in: ":", errPos: 1},
{in: "x:1", errPos: 0},
{in: ":~", errPos: 1},
{in: "@", errPos: 1},
{in: "@:", errPos: 2},
{in: "@x:1", errPos: 1},
{in: "@:~", errPos: 2},
}
for _, test := range tests {
result, err := ParseRange(test.in)
require.Error(t, err, "Expected error, got '%v'", err)
assert.Nil(t, result, "Expected nil result, got '%v'", result)
assert.True(
t, strings.Contains(err.Error(), fmt.Sprintf("at position %d", test.errPos)),
"Expected error to contain '%s', got '%s'", fmt.Sprintf("at position %d", test.errPos), err,
)
}
}