11package structpath
22
33import (
4+ "errors"
45 "fmt"
56 "reflect"
67 "strconv"
8+ "strings"
79
810 "github.com/databricks/cli/libs/structs/structtag"
911)
@@ -145,6 +147,12 @@ func NewAnyIndex(prev *PathNode) *PathNode {
145147}
146148
147149// String returns the string representation of the path.
150+ // The map keys are encoded in single quotes: tags['name']. Single quote can escaped by placing two single quotes: tags[””] (map key is one single quote).
151+ // This encoding is chosen over traditional double quotes because when encoded in JSON it does not need to be escaped:
152+ //
153+ // {
154+ // "resources.jobs.foo.tags['cost-center']": {}
155+ // }
148156func (p * PathNode ) String () string {
149157 if p == nil {
150158 return ""
@@ -166,7 +174,224 @@ func (p *PathNode) String() string {
166174 return prev + "." + p .key
167175 }
168176
169- return fmt .Sprintf ("%s[%q]" , p .prev .String (), p .key )
177+ // Format map key with single quotes, escaping single quotes by doubling them
178+ escapedKey := strings .ReplaceAll (p .key , "'" , "''" )
179+ return fmt .Sprintf ("%s['%s']" , p .prev .String (), escapedKey )
180+ }
181+
182+ // Parse parses a string representation of a path using a state machine.
183+ //
184+ // State Machine for Path Parsing:
185+ //
186+ // States:
187+ // - START: Beginning of parsing, expects field name or "["
188+ // - FIELD_START: After a dot, expects field name only
189+ // - FIELD: Reading field name characters
190+ // - BRACKET_OPEN: Just encountered "[", expects digit, "'" or "*"
191+ // - INDEX: Reading array index digits, expects more digits or "]"
192+ // - MAP_KEY: Reading map key content, expects any char or "'"
193+ // - MAP_KEY_QUOTE: Encountered "'" in map key, expects "'" (escape) or "]" (end)
194+ // - WILDCARD: Reading "*" in brackets, expects "]"
195+ // - EXPECT_DOT_OR_END: After bracket close, expects ".", "[" or end of string
196+ // - END: Successfully completed parsing
197+ //
198+ // Transitions:
199+ // - START: [a-zA-Z_-] -> FIELD, "[" -> BRACKET_OPEN, EOF -> END
200+ // - FIELD_START: [a-zA-Z_-] -> FIELD, other -> ERROR
201+ // - FIELD: [a-zA-Z0-9_-] -> FIELD, "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END
202+ // - BRACKET_OPEN: [0-9] -> INDEX, "'" -> MAP_KEY, "*" -> WILDCARD
203+ // - INDEX: [0-9] -> INDEX, "]" -> EXPECT_DOT_OR_END
204+ // - MAP_KEY: (any except "'") -> MAP_KEY, "'" -> MAP_KEY_QUOTE
205+ // - MAP_KEY_QUOTE: "'" -> MAP_KEY (escape), "]" -> EXPECT_DOT_OR_END (end key)
206+ // - WILDCARD: "]" -> EXPECT_DOT_OR_END
207+ // - EXPECT_DOT_OR_END: "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END
208+ func Parse (s string ) (* PathNode , error ) {
209+ if s == "" {
210+ return nil , nil
211+ }
212+
213+ // State machine states
214+ const (
215+ stateStart = iota
216+ stateFieldStart
217+ stateField
218+ stateBracketOpen
219+ stateIndex
220+ stateMapKey
221+ stateMapKeyQuote
222+ stateWildcard
223+ stateExpectDotOrEnd
224+ stateEnd
225+ )
226+
227+ state := stateStart
228+ var result * PathNode
229+ var currentToken strings.Builder
230+ pos := 0
231+
232+ for pos < len (s ) {
233+ ch := s [pos ]
234+
235+ switch state {
236+ case stateStart :
237+ if ch == '[' {
238+ state = stateBracketOpen
239+ } else if ! isReservedFieldChar (ch ) {
240+ currentToken .WriteByte (ch )
241+ state = stateField
242+ } else {
243+ return nil , fmt .Errorf ("unexpected character '%c' at position %d" , ch , pos )
244+ }
245+
246+ case stateFieldStart :
247+ if ! isReservedFieldChar (ch ) {
248+ currentToken .WriteByte (ch )
249+ state = stateField
250+ } else {
251+ return nil , fmt .Errorf ("expected field name after '.' but got '%c' at position %d" , ch , pos )
252+ }
253+
254+ case stateField :
255+ if ch == '.' {
256+ result = NewStructField (result , reflect .StructTag ("" ), currentToken .String ())
257+ currentToken .Reset ()
258+ state = stateFieldStart
259+ } else if ch == '[' {
260+ result = NewStructField (result , reflect .StructTag ("" ), currentToken .String ())
261+ currentToken .Reset ()
262+ state = stateBracketOpen
263+ } else if ! isReservedFieldChar (ch ) {
264+ currentToken .WriteByte (ch )
265+ } else {
266+ return nil , fmt .Errorf ("invalid character '%c' in field name at position %d" , ch , pos )
267+ }
268+
269+ case stateBracketOpen :
270+ if ch >= '0' && ch <= '9' {
271+ currentToken .WriteByte (ch )
272+ state = stateIndex
273+ } else if ch == '\'' {
274+ state = stateMapKey
275+ } else if ch == '*' {
276+ state = stateWildcard
277+ } else {
278+ return nil , fmt .Errorf ("unexpected character '%c' after '[' at position %d" , ch , pos )
279+ }
280+
281+ case stateIndex :
282+ if ch >= '0' && ch <= '9' {
283+ currentToken .WriteByte (ch )
284+ } else if ch == ']' {
285+ index , err := strconv .Atoi (currentToken .String ())
286+ if err != nil {
287+ return nil , fmt .Errorf ("invalid index '%s' at position %d" , currentToken .String (), pos - len (currentToken .String ()))
288+ }
289+ result = NewIndex (result , index )
290+ currentToken .Reset ()
291+ state = stateExpectDotOrEnd
292+ } else {
293+ return nil , fmt .Errorf ("unexpected character '%c' in index at position %d" , ch , pos )
294+ }
295+
296+ case stateMapKey :
297+ switch ch {
298+ case '\'' :
299+ state = stateMapKeyQuote
300+ default :
301+ currentToken .WriteByte (ch )
302+ }
303+
304+ case stateMapKeyQuote :
305+ switch ch {
306+ case '\'' :
307+ // Escaped quote - add single quote to key and continue
308+ currentToken .WriteByte ('\'' )
309+ state = stateMapKey
310+ case ']' :
311+ // End of map key
312+ result = NewMapKey (result , currentToken .String ())
313+ currentToken .Reset ()
314+ state = stateExpectDotOrEnd
315+ default :
316+ return nil , fmt .Errorf ("unexpected character '%c' after quote in map key at position %d" , ch , pos )
317+ }
318+
319+ case stateWildcard :
320+ if ch == ']' {
321+ // Note, since we're parsing this without type info present, we don't know if it's AnyKey or AnyIndex
322+ // Perhaps structpath should be simplified to have Wildcard as merged representation of AnyKey/AnyIndex
323+ result = NewAnyKey (result )
324+ state = stateExpectDotOrEnd
325+ } else {
326+ return nil , fmt .Errorf ("unexpected character '%c' after '*' at position %d" , ch , pos )
327+ }
328+
329+ case stateExpectDotOrEnd :
330+ switch ch {
331+ case '.' :
332+ state = stateFieldStart
333+ case '[' :
334+ state = stateBracketOpen
335+ default :
336+ return nil , fmt .Errorf ("unexpected character '%c' at position %d" , ch , pos )
337+ }
338+
339+ case stateEnd :
340+ return result , nil
341+
342+ default :
343+ return nil , fmt .Errorf ("parser error at position %d" , pos )
344+ }
345+
346+ pos ++
347+ }
348+
349+ // Handle end-of-input based on final state
350+ switch state {
351+ case stateStart :
352+ return result , nil // Empty path, result is nil
353+ case stateField :
354+ result = NewStructField (result , reflect .StructTag ("" ), currentToken .String ())
355+ return result , nil
356+ case stateExpectDotOrEnd :
357+ return result , nil
358+ case stateFieldStart :
359+ return nil , errors .New ("unexpected end of input after '.'" )
360+ case stateBracketOpen :
361+ return nil , errors .New ("unexpected end of input after '['" )
362+ case stateIndex :
363+ return nil , errors .New ("unexpected end of input while parsing index" )
364+ case stateMapKey :
365+ return nil , errors .New ("unexpected end of input while parsing map key" )
366+ case stateMapKeyQuote :
367+ return nil , errors .New ("unexpected end of input after quote in map key" )
368+ case stateWildcard :
369+ return nil , errors .New ("unexpected end of input after wildcard '*'" )
370+ case stateEnd :
371+ return result , nil
372+ default :
373+ return nil , fmt .Errorf ("parser error at position %d" , pos )
374+ }
375+ }
376+
377+ // isReservedFieldChar checks if character is reserved and cannot be used in field names
378+ func isReservedFieldChar (ch byte ) bool {
379+ switch ch {
380+ case ',' : // Cannot appear in Golang JSON struct tag
381+ return true
382+ case '"' : // Cannot appear in Golang struct tag
383+ return true
384+ case '`' : // Cannot appear in Golang struct tag
385+ return true
386+ case '.' : // Path separator
387+ return true
388+ case '[' : // Bracket notation start
389+ return true
390+ case ']' : // Bracket notation end
391+ return true
392+ default :
393+ return false
394+ }
170395}
171396
172397// Path in libs/dyn format
@@ -192,6 +417,7 @@ func (p *PathNode) DynPath() string {
192417 return p .prev .DynPath () + "[*]"
193418 }
194419
420+ // Both struct fields and map keys use dot notation in DynPath
195421 prev := p .prev .DynPath ()
196422 if prev == "" {
197423 return p .key
0 commit comments