14

I'm looking for a way to replace placeholder strings in a template file with concrete values, with common Unix tools (bash, sed, awk, maybe perl). It is important that the replacement is done in a single pass, that is, what is already scanned/replaced must not be considered for another replacement. For example, these two attempts fail:

echo "AB" | awk '{gsub("A","B");gsub("B","A");print}'
>> AA

echo "AB" | sed 's/A/B/g;s/B/A/g'
>> AA

The correct result in this case is of course BA.

In general, the solution should be equivalent to scanning the input left-to-right for a longest match to one of the given replacement strings, and for each match, performing a replacement and continuing from that point on in the input (none of the already read input nor the replacements performed should be considered for matches). Actually, the details don't matter, just that the results of the replacement are never considered for another replacement, in whole or in part.

NOTE I am only looking for correct generic solutions. Please do not propose solutions which fail for certain inputs (input files, search and replace pairs), however unlikely they may seem.

15
  • 1
    echo "AB"|sed 's/A/!!TMP!!/g;s/B/A/g;s/!!TMP!!/B/g'
    – user55518
    Jun 18, 2014 at 22:33
  • 3
    And frankly, I wouldn't be surprised if someone considered your note a bit rude.
    – peterph
    Jun 18, 2014 at 22:37
  • 1
    How do you expect to "only get correct solutions" when you haven't provided sample input or output?
    – jasonwryan
    Jun 18, 2014 at 22:37
  • 1
    I'm afraid you'll need to do it exactly as you are describing it - parse from the beginning and replace as you go - i.e. not with regular expressions.
    – peterph
    Jun 18, 2014 at 22:44
  • 2
    This is a fair question, but the answer is you need a state machine parser, which is what rici's answer provides (in true hacker style, I think). In other words, you are underestimating the complexity of the task, ala "I want to generically parse (HT|X)ML with regular expressions" -> The answer is NO. You cannot (just) use sed. You cannot (just) use awk. AFAIK there is no existing tool that will do this out of the box. Sans rici's exploit, you'd need to write some code.
    – goldilocks
    Jun 19, 2014 at 4:57

3 Answers 3

12

OK, a general solution. The following bash function requires 2k arguments; each pair consists of a placeholder and a replacement. It's up to you to quote the strings appropriately to pass them into the function. If the number of arguments is odd, an implicit empty argument will be added, which will effectively delete occurrences of the last placeholder.

Neither placeholders nor replacements may contain NUL characters, but you may use standard C \-escapes such as \0 if you need NULs (and consequently you are required to write \\ if you want a \).

It requires the standard build tools which should be present on a posix-like system (lex and cc).

replaceholder() {
  local dir=$(mktemp -d)
  ( cd "$dir"
    { printf %s\\n "%option 8bit noyywrap nounput" "%%"
      printf '"%s" {fputs("%s", yyout);}\n' "${@//\"/\\\"}"
      printf %s\\n "%%" "int main(int argc, char** argv) { return yylex(); }"
    } | lex && cc lex.yy.c
  ) && "$dir"/a.out
  rm -fR "$dir"
}

We assume that \ is already escaped if necessary in the arguments but we need to escape double quotes, if present. That's what the second argument to the second printf does. Since the lex default action is ECHO, we don't need to worry about it.

Example run (with timings for the skeptical; it's just a cheap-o commodity laptop):

$ time echo AB | replaceholder A B B A
BA

real    0m0.128s
user    0m0.106s
sys     0m0.042s
$ time printf %s\\n AB{0000..9999} | replaceholder A B B A > /dev/null

real    0m0.118s
user    0m0.117s
sys     0m0.043s

For larger inputs it might be useful to provide an optimization flag to cc, and for current Posix compatibility, it would be better to use c99. An even more ambitious implementation might try to cache the generated executables instead of generating them each time, but they're not exactly expensive to generate.

Edit

If you have tcc, you can avoid the hassle of creating a temporary directory, and enjoy the faster compile time which will help on normal sized inputs:

treplaceholder () { 
  tcc -run <(
  {
    printf %s\\n "%option 8bit noyywrap nounput" "%%"
    printf '"%s" {fputs("%s", yyout);}\n' "${@//\"/\\\"}"
    printf %s\\n "%%" "int main(int argc, char** argv) { return yylex(); }"
  } | lex -t)
}

$ time printf %s\\n AB{0000..9999} | treplaceholder A B B A > /dev/null

real    0m0.039s
user    0m0.041s
sys     0m0.031s
8
  • 1
    I'm not sure if this is a joke or not ;) Jun 18, 2014 at 23:17
  • 3
    @ambrozbizjak: It works, it's quick for large inputs and acceptably fast for small inputs. It might not use the tools you were thinking of but they are standard tools. Why would it be a joke?
    – rici
    Jun 18, 2014 at 23:49
  • 5
    +1 For not being a joke! :D
    – goldilocks
    Jun 19, 2014 at 4:54
  • That would be POSIX portable like fn() { tcc ; } <<CODE\n$(gen code)\nCODE\n. Can I ask though - this an awesome answer and I upvoted it as soon as I read it - but I don't understand what's happening to the shell array? What does "${@//\"/\\\"}" this do?
    – mikeserv
    Jun 19, 2014 at 5:30
  • @mikeserv: «For each argument as a quoted value ("$@"), replace all (//) occurrences of a quote (\") with (/) a backslash (\\) followed by a quote (\")». See Parameter expansion in the bash manual.
    – rici
    Jun 19, 2014 at 5:34
1
printf 'STRING1STRING1\n\nSTRING2STRING1\nSTRING2\n' |
od -A n -t c -v -w1 |
sed 's/ \{1,3\}//;s/\\$/&&/;H;s/.*//;x
     /\nS\nT\nR\nI\nN\nG\n1/s//STRING2/
     /\nS\nT\nR\nI\nN\nG\n2/s//STRING1/
     /\\n/!{x;d};s/\n//g;s/./\\&/g' |
     xargs printf %b

###OUTPUT###

STRING2STRING2

STRING1STRING2
STRING1

Something like this will always replace each occurrence of your target strings only once as they occur in sed's in stream at one bite per line. This is the fastest way I can imagine you'd do it. Then again, I don't write C. But this does reliably handle null delimiters if you wish it. See this answer for how it works. This has no problems with any contained special shell characters or similar - but it is ASCII locale specific, or, in other words, od will not output multi-byte characters on the same line and will only do one per. If this is a problem you'll want to add in iconv.

5
  • +1 Why do you say it only replaces "the earliest occurrence of your target strings"? In the output it looks as if it replaces all of them. I'm not asking to see it, but could this be done this way without hardcoding the values?
    – goldilocks
    Jun 19, 2014 at 5:29
  • @goldilocks - Yes - but only as soon as they occur. Maybe I should reword that. And yeah - you could just add a middle sed in and save up to a null or something then have that sed write this one's script; or put it in a shell function and give it values at one bite per line like "/$1/"..."/$2/" - maybe I'll write those functions too...
    – mikeserv
    Jun 19, 2014 at 5:36
  • 1
    This doesn't seem to work in the case where the placeholders are PLACE1, PLACE2 and PLA. PLA always wins. OP says: "equivalent to scanning the input left-to-right for a longest match to one of the given replacement strings" (emphasis added)
    – rici
    Jun 19, 2014 at 5:40
  • @rici - thanks. Then I will have to do the null delimiters. Back in a flash.
    – mikeserv
    Jun 19, 2014 at 5:43
  • @rici - I was just about to post another version, that will handle what you describe, but looking at it again and I don't think I should. He says longest for one of the given replacement strings. This does that. There's no indication that one string is a subset of another, only that the replaced value may be. I also don't think iterating over a list is a valid way to solve the problem. Given the problem as I understand it, this is a working solution.
    – mikeserv
    Jun 19, 2014 at 7:13
1

A perl solution. Even if some stated it is not possible, I found one but in general a simple match and replace is not possible and even it gets worse because of the backtracking of a NFA the result can be unexpected.

In general, and this must be said, the problem yelds different results which depend on the order and length of the replacement tuples. i.e.:

A B
AA CC

and the input AAA results in BBB or CCB.

Here the code:

#!/usr/bin/perl

$v='if (0) {} ';
while (($a,$b)=split /\s+/, <DATA>) {
  $k.=$a.'|';
  $v.='elsif ($& eq \''.$a.'\') {print \''.$b.'\'} ';
}
$k.='.';
$v.='else {print $&;}';

eval "
while (<>) {
  \$_ =~ s/($k)/{$v}/geco;
}";  
print "\n";


__DATA__
A    B
B    A
abba baab
baab abbc
abbc aaba

Checkerbunny:

$ echo 'ABBabbaBBbaabAAabbc'|perl script
$ BAAbaabAAabbcBBaaba

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .