Compgen doesn't complete words containing colons correctly - bash

I'm encountering a problem with creating a Bash completion function, when the command is expected to contain colons. When you type a command and press tab, Bash inserts the contents of the command line into an array, only these arrays are broken up by colons. So the command:
dummy foo:apple
Will become:
('dummy' 'foo' ':' 'apple')
I'm aware that one solution is to change COMP_WORDBREAKS, but this isn't an option as it's a team environment, where I could potentially break other code by messing with COMP_WORDBREAKS.
Then this answer suggests using the _get_comp_words_by_ref and __ltrim_colon_completions variables, but it is not remotely clear to me from the answer how to use these.
So I've tried a different solution below. Basically, read the command line as a string, and figure out which word the user's cursor is currently selecting by calculating an "offset". If there is a colon in the command line with text to the left or right of it, it will add 1 each to the offset, and then subtract this from the COMP_CWORD variable.
1 #!/bin/bash
2 _comp() {
3 #set -xv
4 local a=("${COMP_WORDS[#]}")
5 local index=`expr $COMP_CWORD`
6 local c_line="$COMP_LINE"
7
8 # Work out the offset to change the cursor by
9 # This is needed to compensate for colon completions
10 # Because COMP_WORDS splits words containing colons
11 # E.g. 'foo:bar' becomes 'foo' ':' 'bar'.
12
13 # First delete anything in the command to the right of the cursor
14 # We only need from the cursor backwards to work out the offset.
15 for ((i = ${#a[#]}-1 ; i > $index ; i--));
16 do
17 regex="*([[:space:]])"${a[$i]}"*([[:space:]])"
18 c_line="${c_line%$regex}"
19 done
20
21 # Count instances of text adjacent to colons, add that to offset.
22 # E.g. for foo:bar:baz, offset is 4 (bar is counted twice.)
23 # Explanation: foo:bar:baz foo
24 # 0 12 34 5 <-- Standard Bash behaviour
25 # 0 1 <-- Desired Bash behaviour
26 # To create the desired behaviour we subtract offset from cursor index.
27 left=$( echo $c_line | grep -o "[[:alnum:]]:" | wc -l )
28 right=$( echo $c_line | grep -o ":[[:alnum:]]" | wc -l )
29 offset=`expr $left + $right`
30 index=`expr $COMP_CWORD - $offset`
31
32 # We use COMP_LINE (not COMP_WORDS) to get an array of space-separated
33 # words in the command because it will treat foo:bar as one string.
34 local comp_words=($COMP_LINE)
35
36 # If current word is a space, add an empty element to array
37 if [ "${COMP_WORDS[$COMP_CWORD]}" == "" ]; then
38 comp_words=("${comp_words[#]:0:$index}" "" "${comp_words[#]:$index}" )
39 fi
40
41
42 local cur=${comp_words[$index]}
43
44 local arr=(foo:apple foo:banana foo:mango pineapple)
45 COMPREPLY=()
46 COMPREPLY=($(compgen -W "${arr[*]}" -- $cur))
47 #set +xv
48 }
49
50 complete -F _comp dummy
Problem is, this still doesn't work correctly. If I type:
dummy pine<TAB>
Then it will correctly complete dummy pineapple. If I type:
dummy fo<TAB>
Then it will show the three available options, foo:apple foo:banana foo:mango. So far so good. But if I type:
dummy foo:<TAB>
Then the output I get is dummy foo:foo: And then further tabs don't work, because it interprets foo:foo: as a cur, which doesn't match any completion.
When I test the compgen command on its own, like so:
compgen -W 'foo:apple foo:banana foo:mango pineapple' -- foo:
Then it will return the three matching results:
foo:apple
foo:banana
foo:mango
So what I assume is happening is that the Bash completion sees that it has an empty string and three available candidates for completion, so adds the prefix foo: to the end of the command line - even though foo: is already the cursor to be completed.
What I don't understand is how to fix this. When colons aren't involved, this works fine - "pine" will always complete to pineapple. If I go and change the array to add a few more options:
local arr=(foo:apple foo:banana foo:mango pineapple pinecone pinetree)
COMPREPLY=()
COMPREPLY=($(compgen -W "${arr[*]}" -- $cur))
Then when I type dummy pine<TAB> it still happily shows me pineapple pinecone pinetree, and doesn't try to add a superfluous pine on the end.
Is there any fix for this behaviour?

One approach that's worked for me in the past is to wrap the output of compgen in single quotes, e.g.:
__foo_completions() {
COMPREPLY=($(compgen -W "$(echo -e 'pine:cone\npine:apple\npine:tree')" -- "${COMP_WORDS[1]}" \
| awk '{ print "'\''"$0"'\''" }'))
}
foo() {
echo "arg is $1"
}
complete -F __foo_completions foo
Then:
$ foo <TAB>
$ foo 'pine:<TAB>
'pine:apple' 'pine:cone' 'pine:tree'
$ foo 'pine:a<TAB>
$ foo 'pine:apple'<RET>
arg is pine:apple
$ foo pi<TAB>
$ foo 'pine:

Related

Why my default variable is not working in bash script?

Here it is my script.
alias h='history "${1:-25}"'
My desirable result is when it gets variable like h 100 it shows the results of history 100 and no given inputs like h, it shows 25 elements like history 25.
But it works only when I hit h ,showing 25 results, other than that it gave me argument error.
-bash: history: too many arguments
I have tried ${1:-25} but it returns error either.
-bash: $1: cannot assign in this way
Sorry if it is duplicated, but bash script is quite tricky to look up since it has $ and numbers.
An alias can't accept parameters. They take arguments only at the end, and they are not positional (not accessible as $1, $2, etc). It's equivalent to:
alias myAlias="command opt"
myAlias $#
# Is the same as: command opt arg1 arg2 ...
You should use a bash function, one that will receive the param and call history:
function h() {
x="${1:-25}" # Take arg $1, use 25 as fallback
echo "Calling history with: $x" # Log $x value as example
history $x # Call history with $x
}
Usage:
~ $ h
Calling history with: 25
448 xxx
...
471 yyy
472 zzz
~ $ h 1
Calling history with: 1
473 h 1
~ $

Add variable content to a new column in file

I don't have much experience using UNIX and I wonder how to do this:
I have a bash variable with this content:
82 195 9 53
Current file looks like:
A
B
C
D
I want to add a new column to a file with that numbers, like this:
A 82
B 195
C 9
D 53
Hope you can help me. Thanks in advance.
Or simply use paste with a space as the delimiter, e.g. with your example file content in file:
var="82 195 9 53"
paste -d ' ' file <(printf "%s\n" $var)
(note: $var is use unquoted in the process-substitution with printf)
Result
A 82
B 195
C 9
D 53
Note for a general POSIX shell solution, you would simply pipe the output of printf to paste instead of using the bash-only process substitution, e.g.
printf "%s\n" $var | paste -d ' ' file -
With bash and an array:
numbers="82 195 9 53"
array=($numbers)
declare -i c=0 # declare with integer flag
while read -r line; do
echo "$line ${array[$c]}"
c=c+1
done < file
Output:
A 82
B 195
C 9
D 53
One idea using awk:
x='82 195 9 53'
awk -v x="${x}" 'BEGIN { split(x,arr) } { print $0,arr[FNR] }' file.txt
Where:
-v x="${x}" - pass OS variable "${x}" is as awk variable x
split(x,arr) - split awk variable x into array arr[] (default delimiter is space); this will give us arr[1]=82, arr[2]=195, arr[3]=9 and arr[4]=53
This generates:
A 82
B 195
C 9
D 53
The question has been tagged with windows-subsystem-for-linux.
If the input file has Windows/DOS line endings (\r\n) the proposed awk solution may generate incorrect results, eg:
82 # each line
195 # appears
9 # to begin
53 # with a space
In this scenario OP has a couple options:
before calling awk run dos2unix file.txt to convert to unix line engines (\n) or ...
change the awk/BEGIN block to BEGIN { RS="\r\n"; split(x,arr) }
With mapfile aka readarray which is a bash4+ feature.
#!/usr/bin/env bash
##: The variable with numbers
variable='82 195 9 53'
##: Save the the variable into an array named numbers
mapfile -t numbers <<< "${variable// /$'\n'}"
##: Save the content of the file into an array named file_content
mapfile -t file_content < file.txt
##: Loop through the indices of both the arrays and print them side-by-side
for i in "${!file_content[#]}"; do
printf '%s %d\n' "${file_content[i]}" "${numbers[i]}"
done

return values in next line in file on every invocation of script. MIDI Command line sequencer

So i have a file "scenes.txt", containing a 2-column set of numbers, about 500 lines in total:
0 01
6 22
5 03
4 97
7 05
3 98
2 99
9 00 etc...
First column range is 0-9, second column range is 0-99. The 2 fields are tab-separated.
On each invocation of the shell script, I want the script to output the set of the 2 numbers from the next line in the list to 2 variables.
So if I run the script for the first time, var1 should be 0 and var2 should be 01, on the next run of the script var1 should be 6 and var2 should be 22, and so on...
These values are going to the sendmidi command, https://github.com/gbevin/SendMIDI, to control a piece of hardware via MIDI.
You could call it a clumsy MIDI command line sequencer.
what I tried so far: I created a bash script "test1":
#!/bin/bash
read LINECNT < pointer
echo "next line to be processed: $LINECNT"
VAR1=$(sed "${LINECNT}q;d" scenes.txt)
echo "Line and Variables: $LINECNT: $VAR1"
# here the actual magic happens:
/usr/local/bin/sendmidi dev "USB2.0-MIDI Anschluss 2" cc 32 $VAR1 PC $VAR2
((LINECNT++))
echo $LINECNT > pointer
and another file named "pointer", which just holds the pointer position for the value to output upon next invocation.
File pointer:
1
This results in:
[dirk#dirks-MacBook-Pro] ~
❯ ./test2
next line to be processed: 1
Line and Variables: 1: 0 01
[dirk#dirks-MacBook-Pro] ~
❯ ./test2
next line to be processed: 2
Line and Variables: 2: 6 22
[dirk#dirks-MacBook-Pro] ~
❯ ./test2
next line to be processed: 3
Line and Variables: 3: 5 03
[dirk#dirks-MacBook-Pro] ~
❯ ./test2
next line to be processed: 4
Line and Variables: 4: 4 97
[dirk#dirks-MacBook-Pro] ~
❯ ./test2
next line to be processed: 5
Line and Variables: 5: 7 05
My Problem:
There are two Values in each line of "scenes.txt", but the
VAR=$(sed "${LINECNT}q;d" scenes.txt) line only gives the whole line with both values.
I tried several "cut" modifications, like this
VAR1=cut -f1 - $(sed "${LINECNT}q;d" scenes.txt)
VAR2=$(cut -f2 $(sed "${LINECNT}q;d" scenes.txt))
but with no luck...
How do I push the two values from that line into VAR1 and VAR2?
Your sed commands are incorrect. Also, the variables can be read with a single read command:
#!/bin/bash
read -r linecnt < pointer
read -r var1 var2 < <(sed "$linecnt!d; q" scenes.txt)
/usr/local/bin/sendmidi dev "USB2.0-MIDI Anschluss 2" cc 32 "$var1" PC "$var2"
echo $((linecnt + 1)) > pointer
I changed your variable names to lower case since, by convention, capitalized variables are environment variables and internal shell variables.
IFS='\t'
arr=($VAR1)
/usr/local/bin/sendmidi dev "USB2.0-MIDI Anschluss 2" cc 32 ${arr[0]} PC ${arr[1]}

I'm trying to cut a string with a number of bytes. What is the problem with this for loop?

Sorry in advance for the beginner question, but I'm quite stuck and keen to learn.
I am trying to echo a string (in hex) and then cut a piece of that with cut command. It looks like this:
for y in "${Offset}"; do
echo "${entry}" | cut -b 60-$y
done
Where echo ${Offset} results in
75 67 69 129 67 567 69
I would like each entry to be printed, and then cut from the 60th byte until the respective number in $Offset.
So the first entry would be cut 60-75.
However, I get an error:
cut: 67: No such file or directory
cut: 69: No such file or directory
cut: 129: No such file or directory
cut: 67: No such file or directory
cut: 567: No such file or directory
cut: 69: No such file or directory
I tried adding/removing parentheses around each variable but never got the right result.
Any help will be appreciated!
UPDATE: updated the code with changed from markp-fuso. However, this codes still does not work as intended. I would like to print every entry based on the respective offset, but it goes wrong. This prints every entry seven times, where each time is based on seven different offsets. Any ideas on how to fix this?
#!/bin/bash
MESSAGES=$( sqlite3 -csv file.db 'SELECT quote(data) FROM messages' | tr -d "X'" )
for entry in ${MESSAGES}; do
Offset='75 67 69 129 67 567 69'
for y in $Offset; do
echo "${entry:59:(y-59)}"
done
done
echo ${MESSAGES}
Results in seven strings with minimal length 80 bytes and max 600.
My output should be:
String one: cut by first offset
String two: cut by second offset
and so on...
In order for for to iterate over each space-separated "word" in $Offset, you need to get rid of the quotes, which are making it read as a single variable.
for y in ${Offset}; do
echo "${entry}" | cut -b 60-$y
done
To eliminate the sub-process that's going to be invoked due to the | cut ..., we could look at a comparable parameter expansion solution ...
Quick reminder on how to extract a substring from a variable:
${variable:start_position:length}
Keeping in mind that the first character in ${variable} is in position zero/0.
Next, we need to convert each individual offset (y) into a 'length':
length=$((y-60+1))
Rolling these changes into your code (and removing the quotes from around ${Offset}) gives us:
for y in ${Offset}
do
start=$((60-1))
length=$((y-60+1))
echo "${entry:${start}:${length}}"
#echo "${entry:59:(y-59)}"
done
NOTE: You can also replace the start/length/echo with the single commented-out echo.
Using a smaller data set for demo purposes, and using 3 (instead of 60) as the start of our extraction:
# base-10 character position
# 1 2
# 123456789012345678901234567
$ entry='123456789ABCDEFGHIabcdefghi'
$ echo ${#entry} # length of entry?
27
$ Offset='5 8 10 13 20'
$ for y in ${Offset}
do
start=$((3-1))
length=$((y-3+1))
echo "${entry:${start}:${length}}"
done
345 # 3-5
345678 # 3-8
3456789A # 3-10
3456789ABCD # 3-13
3456789ABCDEFGHIab # 3-20
And consolidating the start/length/echo into a single echo:
$ for y in ${Offset}
do
echo "${entry:2:(y-2)}"
done
345 # 3-5
345678 # 3-8
3456789A # 3-10
3456789ABCD # 3-13
3456789ABCDEFGHIab # 3-20

How to nest loops correctly

I have 2 scripts, #1 and #2. Each work OK by themselves. I want to read a 15 row file, row by row, and process it. Script #2 selects rows. Row 0 is is indicated as firstline=0, lastline=1. Row 14 would be firstline=14, lastline=15. I see good results from echo. I want to do the same with script #1. Can't get my head around nesting correctly. Code below.
#!/bin/bash
# script 1
filename=slash
firstline=0
lastline=1
i=0
exec <${filename}
while read ; do
i=$(( $i + 1 ))
if [ "$i" -ge "${firstline}" ] ; then
if [ "$i" -gt "${lastline}" ] ; then
break
else
echo "${REPLY}" > slash1
fold -w 21 -s slash1 > news1
sleep 5
fi
fi
done
# script2
firstline=(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14)
lastline=(1 2 3 4 5 6 7 8 9 10 11 12 13 14 15)
for ((i=0;i<${#firstline[#]};i++))
do
echo ${firstline[$i]} ${lastline[$i]};
done
Your question is very unclear, but perhaps you are simply looking for some simple function calls:
#!/bin/bash
script_1() {
filename=slash
firstline=$1
lastline=$2
i=0
exec <${filename}
while read ; do
i=$(( $i + 1 ))
if [ "$i" -ge "${firstline}" ] ; then
if [ "$i" -gt "${lastline}" ] ; then
break
else
echo "${REPLY}" > slash1
fold -w 21 -s slash1 > news1
sleep 5
fi
fi
done
}
# script2
firstline=(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14)
lastline=(1 2 3 4 5 6 7 8 9 10 11 12 13 14 15)
for ((i=0;i<${#firstline[#]};i++))
do
script_1 ${firstline[$i]} ${lastline[$i]};
done
Note that reading the file this way is extremely inefficient, and there are undoubtedly better ways to handle this, but I am trying to minimize the changes from your code.
Update: Based on your later comments, the following idiomatic Bash code that uses sed to extract the line of interest in each iteration solves your problem much more simply:
Note:
- If the input file does not change between loop iterations, and the input file is small enough (as it is in the case at hand), it's more efficient to buffer the file contents in a variable up front, as is demonstrated in the original answer below.
- As tripleee points out in a comment: If simply reading the input lines sequentially is sufficient (as opposed to extracting lines by specific line numbers, then a single, simple while read -r line; do ... # fold and output, then sleep ... done < "$filename" is enough.
# Determine the input filename.
filename='slash'
# Count its number of lines.
lineCount=$(wc -l < "$filename")
# Loop over the line numbers of the file.
for (( lineNum = 1; lineNum <= lineCount; ++lineNum )); do
# Use `sed` to extract the line with the line number at hand,
# reformat it, and output to the target file.
fold -w 21 -s <(sed -n "$lineNum {p;q;}" "$filename") > 'news1'
sleep 5
done
A simplified version of what I think you're trying to achieve:
#!/bin/bash
# Split fields by newlines on input,
# and separate array items by newlines on output.
IFS=$'\n'
# Read all input lines up front, into array ${lines[#]}
# In terms of your code, you'd use
# read -d '' -ra lines < "$filename"
read -d '' -ra lines <<<$'line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\nline 11\nline 12\nline 13\nline 14\nline 15'
# Define the arrays specifying the line ranges to select.
firstline=(0 1 2 3 4 5 6 7 8 9 10 11 12 13 14)
lastline=(1 2 3 4 5 6 7 8 9 10 11 12 13 14 15)
# Loop over the ranges and select a range of lines in each iteration.
for ((i=0; i<${#firstline[#]}; i++)); do
extractedLines="${lines[*]: ${firstline[i]}: 1 + ${lastline[i]} - ${firstline[i]}}"
# Process the extracted lines.
# In terms of your code, the `> slash1` and `fold ...` commands would go here.
echo "$extractedLines"
echo '------'
done
Note:
The name of the array variable filled with read -ra is lines; ${lines[#]} is Bash syntax for returning all array elements as separate words (${lines[*]} also refers to all elements, but with slightly different semantics), and this syntax is used in the comments to illustrate that lines is indeed an array variable (note that if you were to use simply $lines to reference the variable, you'd implicitly get only the item with index 0, which is the same as: ${lines[0]}.
<<<$'line 1\n...' uses a here-string (<<<) to read an ad-hoc sample document (expressed as an ANSI C-quoted string ($'...')) in the interest of making my example code self-contained.
As stated in the comment, you'd read from $filename instead:
read -d '' -ra lines <"$filename"
extractedLines="${lines[*]: ${firstline[i]}: 1 + ${lastline[i]} - ${firstline[i]}}" extracts the lines of interest; ${firstline[i]} references the current element (index i) from array ${firstline[#]}; since the last token in Bash's array-slicing syntax
(${lines[*]: <startIndex>: <elementCount>}) is the count of elements to return, we must perform a calculation to determine the count, which is what 1 + ${lastline[i]} - ${firstline[i]} does.
By virtue of using "${lines[*]...}" rather than "${lines[#]...}", the extracted array elements are joined by the first character in $IFS, which in our case is a newline ($'\n') (when extracting a single line, that doesn't really matter).

Resources