18

Given a file like this:

a
b
a
b

I'd like to be able to use sed to replace just the last line that contains an instance of "a" in the file. So if I wanted to replace it with "c", then the output should look like:

a
b
c
b

Note that I need this to work irrespective of how many matches it might encounter, or the details of exactly what the desired pattern or file contents might be. Thanks in advance.

TTT
  • 1,175
  • 2
  • 14
  • 32
  • 1
    I still cannot understand your question. You said you want to replace the last line but you replace the second line in your example. – SwiftMango Jun 15 '13 at 07:31
  • 4
    @texasbruce, not the last line in the file, but the last line that matches a specified pattern. – doubleDown Jun 15 '13 at 09:25
  • 1
    To delete rather than replace the last occurrence, change any `s` in the script to a `d`. (There is a duplicate question which asks about this.) – tripleee Jan 25 '17 at 04:33
  • Note [this related question](https://stackoverflow.com/questions/3576139/sed-remove-string-only-in-the-last-line-of-the-file), which is simpler, for when you want to do a sed replacement on the last line of the file. – TamaMcGlinn Jul 06 '22 at 11:53

14 Answers14

15

Not quite sed only:

tac file | sed '/a/ {s//c/; :loop; n; b loop}' | tac

testing

% printf "%s\n" a b a b a b | tac | sed '/a/ {s//c/; :loop; n; b loop}' | tac
a
b
a
b
c
b

Reverse the file, then for the first match, make the substitution and then unconditionally slurp up the rest of the file. Then re-reverse the file.

Note, an empty regex (here as s//c/) means re-use the previous regex (/a/)

I'm not a huge sed fan, beyond very simple programs. I would use awk:

tac file | awk '/a/ && !seen {sub(/a/, "c"); seen=1} 1' | tac
mklement0
  • 382,024
  • 64
  • 607
  • 775
glenn jackman
  • 238,783
  • 38
  • 220
  • 352
  • 2
    +1 @glenn jackman , but i didnt understand the use of `:loop , n , b loop` in the sed command , could you explain that a bit ? – Nitin4873 Jun 15 '13 at 09:30
  • 2
    `tac file|sed '1,/a/s/a/c/'|tac` – jthill Jun 15 '13 at 20:55
  • @NSD, in pseudo-code, `match line with "a", substitute "c" for it, then while true; print the current line and fetch the next line` -- documented [here](http://www.gnu.org/software/sed/manual/html_node/Programming-Commands.html#Programming-Commands) – glenn jackman Jun 16 '13 at 02:01
  • 2
    Turns out sed doesn't do ranges that start and end on the same line, so my idea will fail if the last line in the file is the match. GNU sed has a fix for that specific problem, `0,/a/s/a/c/` will do what I wanted `1,/a/` to do. – jthill Jun 17 '13 at 04:30
  • Very nice solution, but the double tac won't cause a performance problem with large input? – Eran Ben-Natan Jun 17 '13 at 07:13
  • 1
    @jthill: Good point about the need for _GNU_ `sed`'s `0,...` range feature; note that this answer is GNU-specific also, both due to its use of nonstandard utility `tac` and the specific `sed` syntax. On macOS you'd have to use `tail -r | sed -e '/a/ {s//c/; :loop' -e 'n; bloop' -e '}' | tail -r`. It seems that POSIX doesn't mandate a line-reversing utility altogether. – mklement0 Jan 25 '17 at 05:34
  • @nitin In `sed`, `{}` groups multiple commands to the same address space (here the regex `/a/` (i.e. all lines with `a`), `:label` labels the current line in a sed script (label cannot have spaces or be >7 characters), the `n` command goes to the next line (in the sed script), and the `b` command branches to a label (if given) or to the end of the script (if no label given). Labeling is often used with the branch (`b`) and test (`t`) commands to implement flow control in sed programs. Here, we are just looping through each line of the file without doing anything. (Ref: "Sed & Awk" by O'Reilly) – adam.hendry May 05 '21 at 14:58
6

Many good answers here; here's a conceptually simple two-pass sed solution assisted by tail that is POSIX-compliant and doesn't read the whole file into memory, similar to Eran Ben-Natan's approach:

sed "$(sed -n '/a/ =' file | tail -n 1)"' s/a/c/' file
  • sed -n '/a/=' file outputs the numbers of the lines (function =) matching regex a, and tail -n 1 extracts the output's last line, i.e. the number of the line in file file containing the last occurrence of the regex.

  • Placing command substitution $(sed -n '/a/=' file | tail -n 1) directly before ' s/a/c' results in an outer sed script such as 3 s/a/c/ (with the sample input), which performs the desired substitution only on the last on which the regex occurred.

If the pattern is not found in the input file, the whole command is an effective no-op.

Community
  • 1
  • 1
mklement0
  • 382,024
  • 64
  • 607
  • 775
  • Nice explanation. Is Eran's solution not POSIX-compliant? Or is there another reason to use this over that? – TTT Mar 02 '17 at 23:06
  • 2
    @TTT: His is _mostly_ POSIX-compliant (`tail -1` would have to be `tail -n 1`), but primarily his embedded command (`grep` + `cut` + `tail`) is needlessly complicated. Additionally, his command uses legacy command substitution syntax (`\`...\``) instead of the modern `$(...)` syntax. – mklement0 Mar 02 '17 at 23:13
4

This might work for you (GNU sed):

sed -r '/^PATTERN/!b;:a;$!{N;/^(.*)\n(PATTERN.*)/{h;s//\1/p;g;s//\2/};ba};s/^PATTERN/REPLACEMENT/' file

or another way:

sed '/^PATTERN/{x;/./p;x;h;$ba;d};x;/./{x;H;$ba;d};x;b;:a;x;/./{s/^PATTERN/REPLACEMENT/p;d};x' file

or if you like:

sed -r ':a;$!{N;ba};s/^(.*\n?)PATTERN/\1REPLACEMENT/' file

On reflection, this solution may replace the first two:

sed  '/a/,$!b;/a/{x;/./p;x;h};/a/!H;$!d;x;s/^a$/c/M' file

If the regexp is no where to found in the file, the file will pass through unchanged. Once the regex matches, all lines will be stored in the hold space and will be printed when one or both conditions are met. If a subsequent regex is encountered, the contents of the hold space is printed and the latest regex replaces it. At the end of file the first line of the hold space will hold the last matching regex and this can be replaced.

potong
  • 55,640
  • 6
  • 51
  • 83
  • The last one (`sed -r ':a;$!{N;ba};s/^(.*\n?)PATTERN/\1REPLACEMENT/' file`) is my favourite. It uses the greediness of .* to match the last instance rather than the first or any other instance. – Patrick Conheady Sep 17 '18 at 23:11
4

Another approach:

sed "`grep -n '^a$' a | cut -d \: -f 1 | tail -1`s/a/c/" a

The advantage of this approach is that you run sequentially on the file twice, and not read it to memory. This can be meaningful in large files.

Eran Ben-Natan
  • 2,515
  • 2
  • 16
  • 19
  • One small note: I think doing the `cut` after the `tail` would be more computationally efficient. – TTT Mar 02 '17 at 23:06
3

Another one:

tr '\n' ' ' | sed 's/\(.*\)a/\1c/' | tr ' ' '\n'

in action:

$ printf "%s\n" a b a b a b | tr '\n' ' ' | sed 's/\(.*\)a/\1c/' | tr ' ' '\n'
a
b
a
b
c
b
Fredrik Pihl
  • 44,604
  • 7
  • 83
  • 130
  • 2
    If there are blanks in the lines, this will split the lines with blanks. For the given data, it works, but it wouldn't necessarily generalize very well. – Jonathan Leffler Jun 15 '13 at 06:06
  • 1
    but still, this approach is innovative, overcoming `sed`'s line by line processing limitation by removing lines, it can be helpful for many future problems – abasu Jun 15 '13 at 07:28
3

A two-pass solution for when buffering the entire input is intolerable:

sed "$(sed -n /a/= file | sed -n '$s/$/ s,a,c,/p' )" file

(the earlier version of this hit a bug with history expansion encountered on a redhat bash-4.1 install, this way avoids a $!d that was being mistakenly expanded.)

A one-pass solution that buffers as little as possible:

sed '/a/!{1h;1!H};/a/{x;1!p};$!d;g;s/a/c/'

Simplest:

tac | sed '0,/a/ s/a/c/' | tac
jthill
  • 55,082
  • 5
  • 77
  • 137
  • I cannot get your first example to work. Errors (line separations are shown using 6 vertical bars): sed "$(sed -n /a/=file| sed '$df -h;s/$/ s,a,c,/' )"file |||||| sed: -e expression #1, char 3: extra characters after command |||||| sed: -e expression #1, char 5: extra characters after command |||||| sed: -e expression #1, char 1: unknown command: `f' – TTT Dec 20 '16 at 19:01
  • @TTT it looks like you've got history substitution (I had `$!d`, you've got `$df -h`) going on there, did you use singlequotes? Also other things (I've got `/a/= file`, you've got `/a/=file` without the space separating the args). – jthill Dec 21 '16 at 00:04
  • Good catch, I didn't notice that substitution; yes, it was single-quoted, I did a direct copy-paste of your code, I think that is due to the fact that the statement is within the `$()`, since entering '$!d' by itself doesn't cause a substitution. Interestingly, the spaces before *file* that're visible to the eye in your above code don't come through when copy-pasting, so I assumed it wasn't supposed to be in there. Looks like your markup syntax has some funky underscore usage that I'm not familiar with, which is probably doing this. Adding spaces removed the 2nd and 3rd errors. – TTT Dec 21 '16 at 14:07
  • Gaah. For some reason I didn't expect anyone to c&p that, _sorry_. I fixed it so you can do that now. – jthill Dec 21 '16 at 16:02
  • No problem, that resolved 2 of the 3 errors. Still getting the history substitution. I've tried a few different variants to prevent the history substitution, but no luck. According to [this post](http://stackoverflow.com/questions/22125658/how-to-escape-history-expansion-exclamation-mark-inside-a-double-quoted-comm), the only way around it is to temporarily turn off history substitution. I can confirm that this works. Could you update your answer with this? – TTT Dec 21 '16 at 16:28
  • Try a different shell, the one you're using is ridiculously aggressive. There has to be some way to disable it, but if single-quotes won't do it I can't guess what would. On bash and zsh it works fine. – jthill Dec 21 '16 at 16:40
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/131175/discussion-between-ttt-and-jthill). – TTT Dec 21 '16 at 16:42
2

Here is all done in one single awk

awk 'FNR==NR {if ($0~/a/) f=NR;next} FNR==f {$0="c"} 1' file file
a
b
c
b

This reads the file twice. First run to find last a, second run to change it.

Jotne
  • 40,548
  • 12
  • 51
  • 55
1

tac infile.txt | sed "s/a/c/; ta ; b ; :a ; N ; ba" | tac

The first tac reverses the lines of infile.txt, the sed expression (see https://stackoverflow.com/a/9149155/2467140) replaces the first match of 'a' with 'c' and prints the remaining lines, and the last tac reverses the lines back to their original order.

Community
  • 1
  • 1
gbrener
  • 5,365
  • 2
  • 18
  • 20
1

Here is a way with only using awk:

awk '{a[NR]=$1}END{x=NR;cnt=1;while(x>0){a[x]=((a[x]=="a"&&--cnt==0)?"c <===":a[x]);x--};for(i=1;i<=NR;i++)print a[i]}' file
$ cat f
a
b
a
b
f
s
f
e
a
v
$ awk '{a[NR]=$1}END{x=NR;cnt=1;while(x>0){a[x]=((a[x]=="a"&&--cnt==0)?"c <===":a[x]);x--};for(i=1;i<=NR;i++)print a[i]}' f
a
b
a
b
f
s
f
e
c <===
v
jaypal singh
  • 74,723
  • 23
  • 102
  • 147
1

It can also be done in perl:

perl -e '@a=reverse<>;END{for(@a){if(/a/){s/a/c/;last}}print reverse @a}' temp > your_new_file

Tested:

> cat temp
a
b
c
a
b
> perl -e '@a=reverse<>;END{for(@a){if(/a/){s/a/c/;last}}print reverse @a}' temp
a
b
c
c
b
> 
Vijay
  • 65,327
  • 90
  • 227
  • 319
0

Here's the command:

sed '$s/.*/a/' filename.txt

And here it is in action:

> echo "a
> b
> a
> b" > /tmp/file.txt

> sed '$s/.*/a/' /tmp/file.txt
a
b
a
a
Thomas Kelley
  • 10,187
  • 1
  • 36
  • 43
0

Here's another option:

sed -e '$ a a' -e '$ d' file 

The first command appends an a and the second deletes the last line. From the sed(1) man page:

$ Match the last line.

d Delete pattern space. Start next cycle.

a text Append text, which has each embedded newline preceded by a backslash.

Community
  • 1
  • 1
Carl Norum
  • 219,201
  • 40
  • 422
  • 469
0

awk-only solution:

awk '/a/{printf "%s", all; all=$0"\n"; next}{all=all $0"\n"} END {sub(/^[^\n]*/,"c",all); printf "%s", all}' file

Explanation:

  • When a line matches a, all lines between the previous a up to (not including) current a (i.e. the content stored in the variable all) is printed
  • When a line doesn't match a, it gets appended to the variable all.
  • The last line matching a would not be able to get its all content printed, so you manually print it out in the END block. Before that though, you can substitute the line matching a with whatever you desire.
doubleDown
  • 8,048
  • 1
  • 32
  • 48
0

Given:

$ cat file
a
b
a
b

You can use POSIX grep to count the matches:

$ grep -c '^a' file
2

Then feed that number into awk to print a replacement:

$ awk -v last=$(grep -c '^a' file) '/^a/ && ++cnt==last{ print "c"; next } 1' file
a
b
c
b
dawg
  • 98,345
  • 23
  • 131
  • 206