Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
16 changes: 16 additions & 0 deletions classad/classad.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,22 @@ func ParseOld(input string) (*ClassAd, error) {
return &ClassAd{ad: ad}, nil
}

// ParseMultiple parses a string containing one or more concatenated ClassAds
// (e.g., "][" without whitespace) and returns a list of ClassAd objects.
// This function handles the HTCondor format where ClassAds may be concatenated
// without whitespace between them.
func ParseMultiple(input string) ([]*ClassAd, error) {
classads, err := parser.ParseMultipleClassAds(input)
if err != nil {
return nil, err
}
result := make([]*ClassAd, len(classads))
for i, ad := range classads {
result[i] = &ClassAd{ad: ad}
}
return result, nil
}

// String returns the string representation of the ClassAd.
func (c *ClassAd) String() string {
if c.ad == nil {
Expand Down
103 changes: 40 additions & 63 deletions classad/reader.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
// Reader provides an iterator for parsing multiple ClassAds from an io.Reader.
// It supports both new-style (bracketed) and old-style (newline-delimited) formats.
type Reader struct {
classads []*ClassAd
index int
scanner *bufio.Scanner
oldStyle bool
err error
Expand All @@ -18,13 +20,45 @@ type Reader struct {

// NewReader creates a new Reader for parsing new-style ClassAds (with brackets).
// Each ClassAd should be on its own, separated by whitespace or comments.
// This function natively supports concatenated ClassAds (e.g., "][") through
// grammar-level parsing.
// Example format:
//
// [Foo = 1; Bar = 2]
// [Baz = 3; Qux = 4]
//
// Also supports concatenated format:
//
// [Foo = 1; Bar = 2][Baz = 3; Qux = 4]
func NewReader(r io.Reader) *Reader {
// Read all input and parse as multiple ClassAds using the grammar
data, err := io.ReadAll(r)
if err != nil {
return &Reader{
err: err,
}
}

input := strings.TrimSpace(string(data))
if input == "" {
// Empty input - return reader with no ClassAds
return &Reader{
classads: []*ClassAd{},
index: 0,
oldStyle: false,
}
}

classads, err := ParseMultiple(input)
if err != nil {
return &Reader{
err: err,
}
}

return &Reader{
scanner: bufio.NewScanner(r),
classads: classads,
index: 0,
oldStyle: false,
}
}
Expand Down Expand Up @@ -59,71 +93,14 @@ func (r *Reader) Next() bool {
return r.nextNew()
}

// nextNew reads the next new-style ClassAd (with brackets)
// nextNew reads the next new-style ClassAd from the parsed list
func (r *Reader) nextNew() bool {
var lines []string
inClassAd := false
bracketDepth := 0

for r.scanner.Scan() {
line := strings.TrimSpace(r.scanner.Text())

// Skip empty lines and comments outside of ClassAds
if !inClassAd && (line == "" || strings.HasPrefix(line, "//") || strings.HasPrefix(line, "/*")) {
continue
}

// Check if this line starts a ClassAd
if !inClassAd && strings.HasPrefix(line, "[") {
inClassAd = true
}

if inClassAd {
lines = append(lines, line)

// Count brackets to handle nested ClassAds
for _, ch := range line {
switch ch {
case '[':
bracketDepth++
case ']':
bracketDepth--
}
}

// If we've closed all brackets, we have a complete ClassAd
if bracketDepth == 0 {
classAdStr := strings.Join(lines, "\n")
ad, err := Parse(classAdStr)
if err != nil {
r.err = err
return false
}
r.current = ad
return true
}
}
}

// Check for scanner errors
if err := r.scanner.Err(); err != nil {
r.err = err
if r.index >= len(r.classads) {
return false
}

// If we have accumulated lines but hit EOF, try to parse them
if len(lines) > 0 {
classAdStr := strings.Join(lines, "\n")
ad, err := Parse(classAdStr)
if err != nil {
r.err = err
return false
}
r.current = ad
return true
}

return false
r.current = r.classads[r.index]
r.index++
return true
}

// nextOld reads the next old-style ClassAd (newline-delimited, separated by blank lines)
Expand Down
71 changes: 71 additions & 0 deletions classad/reader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -566,3 +566,74 @@ Name = "third"`
t.Errorf("Expected 3 ClassAds, got %d", count)
}
}

// TestNewReader_ConcatenatedClassAds tests parsing ClassAds that are concatenated
// without whitespace between them (e.g., "]["). This format is used by HTCondor
// when writing ClassAds to plugin input files.
func TestNewReader_ConcatenatedClassAds(t *testing.T) {
// Test concatenated format without newlines: ][
input := `[Url = "pelican://example.com/file1"; LocalFileName = "/tmp/file1"][Url = "pelican://example.com/file2"; LocalFileName = "/tmp/file2"][Url = "pelican://example.com/file3"; LocalFileName = "/tmp/file3"]`
reader := NewReader(strings.NewReader(input))

count := 0
urls := []string{}

for reader.Next() {
ad := reader.ClassAd()
url, ok := ad.EvaluateAttrString("Url")
if !ok {
t.Error("Expected Url attribute")
}
urls = append(urls, url)
count++
}

if reader.Err() != nil {
t.Errorf("Unexpected error: %v", reader.Err())
}

if count != 3 {
t.Errorf("Expected 3 ClassAds, got %d", count)
}

expectedUrls := []string{
"pelican://example.com/file1",
"pelican://example.com/file2",
"pelican://example.com/file3",
}
for i, url := range urls {
if url != expectedUrls[i] {
t.Errorf("Expected Url=%s, got %s", expectedUrls[i], url)
}
}
}

// TestAll_ConcatenatedClassAds tests the iterator pattern with concatenated ClassAds
func TestAll_ConcatenatedClassAds(t *testing.T) {
// Test concatenated format without newlines: ][
input := `[ID = 1; Name = "first"][ID = 2; Name = "second"][ID = 3; Name = "third"]`

count := 0
ids := []int64{}

All(strings.NewReader(input))(func(ad *ClassAd) bool {
count++
id, ok := ad.EvaluateAttrInt("ID")
if !ok {
t.Error("Expected ID attribute")
}
ids = append(ids, id)
return true
})

if count != 3 {
t.Errorf("Expected 3 ClassAds, got %d", count)
}

expectedIds := []int64{1, 2, 3}
for i, id := range ids {
if id != expectedIds[i] {
t.Errorf("Expected ID=%d, got %d", expectedIds[i], id)
}
}
}
18 changes: 15 additions & 3 deletions parser/classad.y
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
node ast.Node
expr ast.Expr
classad *ast.ClassAd
classads []*ast.ClassAd
attr *ast.AttributeAssignment
attrs []*ast.AttributeAssignment
exprlist []ast.Expr
Expand Down Expand Up @@ -42,6 +43,7 @@ import (
%left '.' '[' '('

%type <classad> classad record_literal
%type <classads> classad_list
%type <attrs> attr_list
%type <attr> attr_assign
%type <expr> expr literal primary_expr postfix_expr unary_expr
Expand All @@ -53,14 +55,24 @@ import (
%%

start
: classad
: classad_list
{
if lex, ok := yylex.(interface{ SetResult(ast.Node) }); ok {
lex.SetResult($1)
if lex, ok := yylex.(interface{ SetResultList([]*ast.ClassAd) }); ok {
lex.SetResultList($1)
} else if lex, ok := yylex.(interface{ SetResult(ast.Node) }); ok && len($1) == 1 {
// For backward compatibility: if only one ClassAd, set as single result
lex.SetResult($1[0])
}
}
;

classad_list
: classad
{ $$ = []*ast.ClassAd{$1} }
| classad_list classad
{ $$ = append($1, $2) }
;

classad
: '[' attr_list ']'
{ $$ = &ast.ClassAd{Attributes: $2} }
Expand Down
26 changes: 20 additions & 6 deletions parser/lexer.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@ type Token struct {

// Lexer represents a lexical scanner for ClassAd expressions.
type Lexer struct {
input string
pos int
result ast.Node
err error
input string
pos int
result ast.Node
resultList []*ast.ClassAd
err error
}

// NewLexer creates a new lexer for the given input.
func NewLexer(input string) *Lexer {
return &Lexer{
input: input,
pos: 0,
input: input,
pos: 0,
result: nil,
resultList: nil,
err: nil,
}
}

Expand Down Expand Up @@ -210,11 +214,21 @@ func (l *Lexer) Result() (ast.Node, error) {
return l.result, l.err
}

// ResultList returns the parsed list of ClassAds and any error.
func (l *Lexer) ResultList() ([]*ast.ClassAd, error) {
return l.resultList, l.err
}

// SetResult sets the parse result.
func (l *Lexer) SetResult(node ast.Node) {
l.result = node
}

// SetResultList sets the parse result as a list of ClassAds.
func (l *Lexer) SetResultList(classads []*ast.ClassAd) {
l.resultList = classads
}

func (l *Lexer) peek() rune {
if l.pos >= len(l.input) {
return 0
Expand Down
48 changes: 48 additions & 0 deletions parser/multi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package parser

import (
"testing"
)

func TestParseMultipleClassAds(t *testing.T) {
tests := []struct {
name string
input string
expected int
}{
{
name: "single ClassAd",
input: `[Foo = 1; Bar = 2]`,
expected: 1,
},
{
name: "concatenated ClassAds",
input: `[Foo = 1][Bar = 2][Baz = 3]`,
expected: 3,
},
{
name: "concatenated with attributes",
input: `[Url = "file1"; LocalFileName = "/tmp/file1"][Url = "file2"; LocalFileName = "/tmp/file2"]`,
expected: 2,
},
{
name: "concatenated with whitespace",
input: `[Foo = 1]
[Bar = 2]
[Baz = 3]`,
expected: 3,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
classads, err := ParseMultipleClassAds(tt.input)
if err != nil {
t.Fatalf("ParseMultipleClassAds() error = %v", err)
}
if len(classads) != tt.expected {
t.Errorf("ParseMultipleClassAds() returned %d ClassAds, expected %d", len(classads), tt.expected)
}
})
}
}
Loading
Loading