Skip to content

Commit 09a557f

Browse files
committed
conf: support optional include? directive
Add parsing support for `include? <path>` so missing include files are ignored. Keep existing behavior for invalid include files and add lexer/parser tests. Implements: #5297 Signed-off-by: Ollie Hensman-Crook <olliehensmancrook@gmail.com>
1 parent 51fbade commit 09a557f

File tree

4 files changed

+205
-1
lines changed

4 files changed

+205
-1
lines changed

conf/lex.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const (
5454
itemCommentStart
5555
itemVariable
5656
itemInclude
57+
itemIncludeOptional
5758
)
5859

5960
const (
@@ -486,6 +487,12 @@ func (lx *lexer) keyCheckKeyword(fallThrough, push stateFn) stateFn {
486487
lx.push(push)
487488
}
488489
return lexIncludeStart
490+
case "include?":
491+
lx.ignore()
492+
if push != nil {
493+
lx.push(push)
494+
}
495+
return lexIncludeOptionalStart
489496
}
490497
lx.emit(itemKey)
491498
return fallThrough
@@ -501,6 +508,16 @@ func lexIncludeStart(lx *lexer) stateFn {
501508
return lexInclude
502509
}
503510

511+
// lexIncludeOptionalStart will consume the whitespace til the start of the value.
512+
func lexIncludeOptionalStart(lx *lexer) stateFn {
513+
r := lx.next()
514+
if isWhitespace(r) {
515+
return lexSkip(lx, lexIncludeOptionalStart)
516+
}
517+
lx.backup()
518+
return lexIncludeOptional
519+
}
520+
504521
// lexIncludeQuotedString consumes the inner contents of a string. It assumes that the
505522
// beginning '"' has already been consumed and ignored. It will not interpret any
506523
// internal contents.
@@ -582,6 +599,83 @@ func lexInclude(lx *lexer) stateFn {
582599
return lexIncludeString
583600
}
584601

602+
// lexIncludeOptional will consume the optional include value.
603+
func lexIncludeOptional(lx *lexer) stateFn {
604+
r := lx.next()
605+
switch {
606+
case r == sqStringStart:
607+
lx.ignore() // ignore the " or '
608+
return lexIncludeOptionalQuotedString
609+
case r == dqStringStart:
610+
lx.ignore() // ignore the " or '
611+
return lexIncludeOptionalDubQuotedString
612+
case r == arrayStart:
613+
return lx.errorf("Expected include value but found start of an array")
614+
case r == mapStart:
615+
return lx.errorf("Expected include value but found start of a map")
616+
case r == blockStart:
617+
return lx.errorf("Expected include value but found start of a block")
618+
case unicode.IsDigit(r), r == '-':
619+
return lx.errorf("Expected include value but found start of a number")
620+
case r == '\\':
621+
return lx.errorf("Expected include value but found escape sequence")
622+
case isNL(r):
623+
return lx.errorf("Expected include value but found new line")
624+
}
625+
lx.backup()
626+
return lexIncludeOptionalString
627+
}
628+
629+
// lexIncludeOptionalQuotedString consumes the inner contents of a string.
630+
func lexIncludeOptionalQuotedString(lx *lexer) stateFn {
631+
r := lx.next()
632+
switch {
633+
case r == sqStringEnd:
634+
lx.backup()
635+
lx.emit(itemIncludeOptional)
636+
lx.next()
637+
lx.ignore()
638+
return lx.pop()
639+
case r == eof:
640+
return lx.errorf("Unexpected EOF in quoted include")
641+
}
642+
return lexIncludeOptionalQuotedString
643+
}
644+
645+
// lexIncludeOptionalDubQuotedString consumes the inner contents of a string.
646+
func lexIncludeOptionalDubQuotedString(lx *lexer) stateFn {
647+
r := lx.next()
648+
switch {
649+
case r == dqStringEnd:
650+
lx.backup()
651+
lx.emit(itemIncludeOptional)
652+
lx.next()
653+
lx.ignore()
654+
return lx.pop()
655+
case r == eof:
656+
return lx.errorf("Unexpected EOF in double quoted include")
657+
}
658+
return lexIncludeOptionalDubQuotedString
659+
}
660+
661+
// lexIncludeOptionalString consumes the inner contents of a raw string.
662+
func lexIncludeOptionalString(lx *lexer) stateFn {
663+
r := lx.next()
664+
switch {
665+
case isNL(r) || r == eof || r == optValTerm || r == mapEnd || isWhitespace(r):
666+
lx.backup()
667+
lx.emit(itemIncludeOptional)
668+
return lx.pop()
669+
case r == sqStringEnd:
670+
lx.backup()
671+
lx.emit(itemIncludeOptional)
672+
lx.next()
673+
lx.ignore()
674+
return lx.pop()
675+
}
676+
return lexIncludeOptionalString
677+
}
678+
585679
// lexKey consumes the text of a key. Assumes that the first character (which
586680
// is not whitespace) has already been consumed.
587681
func lexKey(lx *lexer) stateFn {
@@ -1302,6 +1396,8 @@ func (itype itemType) String() string {
13021396
return "Variable"
13031397
case itemInclude:
13041398
return "Include"
1399+
case itemIncludeOptional:
1400+
return "IncludeOptional"
13051401
}
13061402
panic(fmt.Sprintf("BUG: Unknown type '%s'.", itype.String()))
13071403
}

conf/lex_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1412,6 +1412,25 @@ func TestInclude(t *testing.T) {
14121412
expect(t, lx, expectedItems)
14131413
}
14141414

1415+
func TestOptionalInclude(t *testing.T) {
1416+
expectedItems := []item{
1417+
{itemIncludeOptional, "users.conf", 1, 10},
1418+
{itemEOF, "", 1, 0},
1419+
}
1420+
lx := lex("include? \"users.conf\"")
1421+
expect(t, lx, expectedItems)
1422+
1423+
lx = lex("include? 'users.conf'")
1424+
expect(t, lx, expectedItems)
1425+
1426+
expectedItems = []item{
1427+
{itemIncludeOptional, "users.conf", 1, 9},
1428+
{itemEOF, "", 1, 0},
1429+
}
1430+
lx = lex("include? users.conf")
1431+
expect(t, lx, expectedItems)
1432+
}
1433+
14151434
func TestMapInclude(t *testing.T) {
14161435
expectedItems := []item{
14171436
{itemKey, "foo", 1, 0},
@@ -1455,6 +1474,18 @@ func TestMapInclude(t *testing.T) {
14551474
expect(t, lx, expectedItems)
14561475
}
14571476

1477+
func TestMapOptionalInclude(t *testing.T) {
1478+
expectedItems := []item{
1479+
{itemKey, "foo", 1, 0},
1480+
{itemMapStart, "", 1, 5},
1481+
{itemIncludeOptional, "users.conf", 1, 15},
1482+
{itemMapEnd, "", 1, 27},
1483+
{itemEOF, "", 1, 0},
1484+
}
1485+
lx := lex("foo { include? users.conf }")
1486+
expect(t, lx, expectedItems)
1487+
}
1488+
14581489
func TestJSONCompat(t *testing.T) {
14591490
for _, test := range []struct {
14601491
name string

conf/parse.go

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ func ParseWithChecks(data string) (map[string]any, error) {
8989
func ParseFile(fp string) (map[string]any, error) {
9090
data, err := os.ReadFile(fp)
9191
if err != nil {
92-
return nil, fmt.Errorf("error opening config file: %v", err)
92+
return nil, fmt.Errorf("error opening config file: %w", err)
9393
}
9494

9595
p, err := parse(string(data), fp, false)
@@ -435,6 +435,33 @@ func (p *parser) processItem(it item, fp string) error {
435435
for k, v := range m {
436436
p.pushKey(k)
437437

438+
if p.pedantic {
439+
switch tk := v.(type) {
440+
case *token:
441+
p.pushItemKey(tk.item)
442+
}
443+
}
444+
p.setValue(v)
445+
}
446+
case itemIncludeOptional:
447+
var (
448+
m map[string]any
449+
err error
450+
)
451+
if p.pedantic {
452+
m, err = ParseFileWithChecks(filepath.Join(p.fp, it.val))
453+
} else {
454+
m, err = ParseFile(filepath.Join(p.fp, it.val))
455+
}
456+
if err != nil {
457+
if os.IsNotExist(err) || strings.Contains(strings.ToLower(err.Error()), "no such file or directory") {
458+
break
459+
}
460+
return fmt.Errorf("error parsing include file '%s', %v", it.val, err)
461+
}
462+
for k, v := range m {
463+
p.pushKey(k)
464+
438465
if p.pedantic {
439466
switch tk := v.(type) {
440467
case *token:

conf/parse_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,56 @@ func TestIncludes(t *testing.T) {
364364
}
365365
}
366366

367+
func TestOptionalIncludeMissingFile(t *testing.T) {
368+
sdir := t.TempDir()
369+
cfg := `
370+
listen: 127.0.0.1:4222
371+
include? ./nats-cluster.conf
372+
`
373+
fp := filepath.Join(sdir, "nats.conf")
374+
if err := os.WriteFile(fp, []byte(cfg), 0666); err != nil {
375+
t.Fatal(err)
376+
}
377+
378+
m, err := ParseFile(fp)
379+
if err != nil {
380+
t.Fatalf("Received err: %v\n", err)
381+
}
382+
if got := m["listen"]; got != "127.0.0.1:4222" {
383+
t.Fatalf("Expected listen to be set, got: %v", got)
384+
}
385+
386+
m, err = ParseFileWithChecks(fp)
387+
if err != nil {
388+
t.Fatalf("Received err: %v\n", err)
389+
}
390+
if got := m["listen"]; got.(*token).Value() != "127.0.0.1:4222" {
391+
t.Fatalf("Expected listen to be set, got: %v", got)
392+
}
393+
}
394+
395+
func TestOptionalIncludeInvalidFile(t *testing.T) {
396+
sdir := t.TempDir()
397+
cfg := `
398+
listen: 127.0.0.1:4222
399+
include? ./nats-cluster.conf
400+
`
401+
fp := filepath.Join(sdir, "nats.conf")
402+
if err := os.WriteFile(fp, []byte(cfg), 0666); err != nil {
403+
t.Fatal(err)
404+
}
405+
if err := os.WriteFile(filepath.Join(sdir, "nats-cluster.conf"), []byte("?????????????"), 0666); err != nil {
406+
t.Fatal(err)
407+
}
408+
409+
if _, err := ParseFile(fp); err == nil {
410+
t.Fatal("expected an error")
411+
}
412+
if _, err := ParseFileWithChecks(fp); err == nil {
413+
t.Fatal("expected an error")
414+
}
415+
}
416+
367417
var varIncludedVariablesSample = `
368418
authorization {
369419

0 commit comments

Comments
 (0)