Split dotted path but replace two dots with a symbol

Viewed 111

In my domain-specific language, I am able to reference something inside a tree-like structure using a dotted path, except that I can also go "up" with two dots in a row:

"sibling"         // gets sibling
".sibling"        // same as above
".sibling.nested" // indexes into sibling with 'nested'
"..down"          // goes up the hierarchy, then down the key 'down'
"down.."          // doesn't do anything, really, goes down and then back up
".."              // references parent
"down..down"      // same as "down"

I need to split the above like this:

["sibling"]
["sibling"]
["sibling", "nested"]
[symbol, "down"]
["down", symbol]
[symbol]
["down", symbol, "down"]

In essence, replacing the .. with a symbol, but splitting by single . normally. I can split plain dotted nested paths like this (even respects escaping the period):

path.split(/(?<!\\)\./)

Keys can potentially contain any characters, so a word boundary will not work sometimes.

But I'm having a hard time finding a clean solution that can replace the ... My current solution is to replace all empty strings with the symbol (empty strings mean that there were two periods in a row), and append a character to the ends of the path, removing after the splitting is done:

const symbol = Symbol();

function split(path) {
  path = "$" + path + "$"; // Any character can work; I just used '$' here
  // Split by dots, replacing empty strings with the symbol
  const split = path.split(/(?<!\\)\./).map((part) => (!part ? symbol : part));
  // Remove prefixed character from start
  split[0] = split[0].slice(1);
  // Remove suffixed character from end
  split[split.length - 1] = split[split.length - 1].slice(0, -1);
  // Remove start or end if they are empty
  if (!split[0]) split.shift();
  if (!split[split.length - 1]) split.pop();
  // Done
  return split;
}

const tests = [
  "sibling"        , // gets sibling
  ".sibling"       , // same as above
  ".sibling.nested", // indexes into sibling with 'nested'
  "..down"         , // goes up the hierarchy, then down the key 'down'
  "down.."         , // doesn't do anything, really, goes down and then back up
  ".."             , // references parent
  "down..down"     , // same as "down"
];
// (Stack Overflow snippet console displays null instead of Symbol)
console.log(tests.map(split));

It gets the job done, passes all the test cases, but it's much too verbose and clumsy. I'm looking for a hopefully shorter and easier solution than this.

3 Answers

If I understood correctly (at least my solution also passed all the tests), here is my shorter version:

let f = str => str.split(/\b/g).filter(x=> x!='.').map(x=> x=='..'? Symbol() :x);
const tests = [
  "sibling"        , // gets sibling
  ".sibling"       , // same as above
  ".sibling.nested", // indexes into sibling with 'nested'
  "..down"         , // goes up the hierarchy, then down the key 'down'
  "down.."         , // doesn't do anything, really, goes down and then back up
  ".."             , // references parent
  "down..down"     , // same as "down"
];
console.log(tests.map(f));

Quick explanation: I suggest that every tree node is a single 'word' then I split the string by word boundaries which is easy with help of regex's \b and the rest is obvious I guess

UPD.

My 2d version that could handle even 'asd.$..a#123s.asdasd' to correctly translate it into [ 'asd', '$', Symbol(), 'a#123s', 'asdasd' ] is here

const f = str => str.split(/(?<!\.)\.(?!\.)/gm).map(x=> x.split(/\.\./).flatMap(e => [e,Symbol()]).slice(0,-1)).flat();

const tests = [
  "sibling"        , // gets sibling
  ".sibling"       , // same as above
  ".sibling.nested", // indexes into sibling with 'nested'
  "..down"         , // goes up the hierarchy, then down the key 'down'
  "down.."         , // doesn't do anything, really, goes down and then back up
  ".."             , // references parent
  "down..down"     , // same as "down"
  "asd.$..a#123s.asdasd", //add test with any symbol inside
];

console.log(tests.map(f));

The main idea behind my second variation is to firstly find single . (before or after which there are no extra dots) and split by it.
So with my example we will transform "asd.$..a#123s.asdasd" into ["asd", "$..a#123s","asdasd].
The socond part as easy as first. We have to split the rest by .. and insert the Symbol() in between.

P.S.slice(0,-1) prevents redundant Symbol() at the end of each sub array.

My trick is to split on . and .., but include the last . if it exists

const symbol = Symbol();

// Your function
function split(path) {
  path = "$" + path + "$"; // Any character can work; I just used '$' here
  // Split by dots, replacing empty strings with the symbol
  const split = path.split(/(?<!\\)\./).map((part) => (!part ? symbol : part));
  // Remove prefixed character from start
  split[0] = split[0].slice(1);
  // Remove suffixed character from end
  split[split.length - 1] = split[split.length - 1].slice(0, -1);
  // Remove start or end if they are empty
  if (!split[0]) split.shift();
  if (!split[split.length - 1]) split.pop();
  // Done
  return split;
}

function split2(path) {
  return path.split(/(?<!\\)\.(\.)?/g) // Split on . and .., but include the last . if exists
             .filter(s => s) // Remove falsey things
             .map(s => s === '.' ? symbol : s)
}

const tests = [
  "sibling"        , // gets sibling
  ".sibling"       , // same as above
  ".sibling.nested", // indexes into sibling with 'nested'
  "..down"         , // goes up the hierarchy, then down the key 'down'
  "down.."         , // doesn't do anything, really, goes down and then back up
  ".."             , // references parent
  "down..down"     , // same as "down"
];
console.log(tests.map(split));
console.log(tests.map(split2))

Output

[
  [ 'sibling' ],
  [ 'sibling' ],
  [ 'sibling', 'nested' ],
  [ Symbol(), 'down' ],
  [ 'down', Symbol() ],
  [ Symbol() ],
  [ 'down', Symbol(), 'down' ]
]
[
  [ 'sibling' ],
  [ 'sibling' ],
  [ 'sibling', 'nested' ],
  [ Symbol(), 'down' ],
  [ 'down', Symbol() ],
  [ Symbol() ],
  [ 'down', Symbol(), 'down' ]
]

EDIT:

  1. Better algorithm, better writing
  2. Change from including the first . to the last . as it just sounds more natural to do. (Should be no difference in usage)

I'm not sure if this works for you, but I get the same output on your tests. It's only 2 lines less and little more readable though.

function split2(path: string) {
    path = path.replace('..', '.$.')
    let split = path.split('.').filter(x => x)
    split = split.map(function(x){return x === '$' ? symbol : x});
    return split;
}

Here's a playground.

Related