#!/usr/bin/env bash
. "${0%/*}"/../lib/common.bashlib || exit 1 #C#
init
depend cksum readlink sed tee
usage <<-EOU
	Usage: ${0##*/} [sed option]... [--qa-sed-args] [option]...

	sed wrapper that reports when all files were unmodified to help with
	detection of outdated constructs similarly to a failing patch.

	This is primarily meant to be integrated with portage rather than used
	on its own (but still can), see portage integration below.

	Options:
	      --qa-sed-args  Allow other options to be used and not passed to sed

	      --lineno=NUM   Line number that called sed (for referencing)
	      --source=FILE  Path to the source file calling sed (for referencing)

	  -X, --error-on-qa  Return exit code >128 if QA issues rather than sed's own
	                     (will trigger \`|| die\` in ebuilds)

	      --confdir=PATH Configuration dir to use instead of defaults
	                     (@confdir@ + ${XDG_CONFIG_HOME:-~/.config}/@package@)
	      --dumpconfig   Display config and exit (2> ${0##*/}.conf)

	  -h, --help         Display usage information and exit
	      --version      Display version information and exit

	*Caveats*
	 *  does not know if a sed only replaces under specific conditions
	    e.g. 's|/usr|\${EPREFIX}/usr|', or 's|lib|\$(get_libdir)|'
	    consider: use prefix, [[ \$(get_libdir) != lib ]], placeholders, etc...
	 *  can only verify different sed expressions if passed as separate
	    -e arguments rather than a single 's/a/b/; s/c/d/'
	 *  can't do detection if stdin is used and isn't a file
	    e.g. \`cat file | sed\` rather than \`sed < file\` (latter works)
	    this is due to a precaution to ensure input will not be mangled
	 *  can't handle e/r/w sed commands (will abort with a sandbox error)

	*Portage Integration*
	Can be integrated by using ${EROOT}/etc/portage/bashrc, either by using the
	example ${ROOT}@datadir@/bashrc or by manually adding:

	    source @datadir@/${0##*/}.bashrc

	Creates a sed() function that most things will use instead. Should, in
	theory, not break anything as it will call sed with same arguments even
	on error -- but use with caution nonetheless, not for production.
	Note that \`find . -exec sed {} +\` bypasses the shell wrapper entirely.

	bashrc environment options (export/make.conf/package.env):
	  QA_SED=y | =n           Enable or disable, can also use IWDT_ALL=y | =n
	  QA_SED_CMD=${0##*/}       This script, needs to be changed if not in PATH
	  QA_SED_ARGS=            Extra arguments to pass, see options above
	  QA_SED_LOG=eqawarn      Portage output command, can also use IWDT_LOG=ewarn
	  QA_SED_PHASEONLY=y | =n By default QA will only be shown for seds ran
	                          directly in phase functions (e.g. src_prepare) to
	                          suppress noise coming from eclasses
	  (a known limitation of PHASEONLY=y is that using a non-eclass ebuild
	  function also ignores the sed except for the python_prepare_all function)
	Note: eqawarn post-emerge log needs "qa" in make.conf's PORTAGE_ELOG_CLASSES
EOU

setmsg 2 # this must not output anything to stdout if running sed

# split sed's and our own arguments
SED_ARGV=()
while (( ${#} )); do
	if [[ ${1} == --qa-sed-args ]]; then
		shift
		break
	fi
	SED_ARGV+=("${1}")
	shift
done

# setup end()/die() hook early to ensure sed runs
QA_ERRNO=
hook_end() {
	sed "${SED_ARGV[@]}"
	local errno=${?}

	[[ -v O[error-on-qa] ]] && ${O[error-on-qa]} && errno=${QA_ERRNO:-${errno}}

	exit "${errno}"
}

sed-qa() {
	QA_ERRNO=${1}
	shift

	msg "SED: ${*}"

	if [[ ${O[source]} && ${O[lineno]} ]]; then
		# display sed line from source file
		showline "${O[lineno]}" "${O[source]}" %s >&2
	else
		# if no source, display rough expanded arguments
		msg "sed ${SED_ARGV[*]}"
	fi
}

optauto args "${@}" <<-EOO
	qa-sed-args=ignore
	lineno=int:
	source=str:
	error-on-qa=bool:false
EOO
unset args

# Need to 1. get file list from arguments given to sed which could
# be in any order, and 2. create separate argument list with only a
# single expression and no -i to allow evaluating changes per-expression
# To do this, parse relevant arguments as sed would.
# e.g. `sed -i file -e s/a/b/ -Ee s/c/d/ -f script` results in
# -> 1. sed --expression=s/a/b/ -E file
#    2. sed -E --expression=s/c/d/ file # -E order matters, only for 2nd+
#    3. sed -E --file=script file
declare -i pass=1
pass_1=(--sandbox)
pass_all=("${pass_1[@]}")
sed-pass_add() {
	local -i i
	for ((i=1; i<=pass; i++)); do
		local -n ref=pass_${i}
		ref+=("${1}")
	done
	pass_all+=("${1}")
}
sed-pass_fork() {
	local -n prevref=pass_${pass}
	local -n nextref=pass_$((++pass))
	nextref=("${prevref[@]}") #!SC2034
	prevref+=("${1}")
	pass_all+=("${1}")
}

exps=()
inplace=false
getoptw -n arg in \
	hnErszi::e:f:l: \
	help,version,quiet,silent,posix,regexp-extended,separate,null-data,in-place::,expression:,file:,line-length: \
	"${SED_ARGV[@]}" 2>/dev/null # ignore errors from other arguments
while getoptw; do
	case ${arg} in
		-h|--help) usage;; # assume it was meant for us
		--version) version;;
		-e|--expression)
			getoptw
			exps+=("-e ${arg}")
			sed-pass_fork --expression="${arg}"
		;;
		-f|--file)
			getoptw
			exps+=("-f ${arg}")
			sed-pass_fork --file="${arg}"
		;;
		-i|--in-place)
			getoptw
			inplace=true
		;;
		-l|--line-length) getoptw;; # unused
		*) sed-pass_add "${arg}";;
	esac
done

if (( pass > 1)); then
	unset pass_$((pass--)) # cleanup unused fork
elif (( ${#in[@]} )); then
	# if neither -e nor -f is specified, sed has a rule where first
	# non-argument is taken as an expression rather than a file
	exps=("-e ${in[0]}")
	pass_1+=( --expression="${in[0]}" )
	in=("${in[@]:1}")
else
	end # no expressions, sed will fail
fi

# sed labels and potentially other constructs may not function when
# take each -e individually, perform a dry run and merge if needed
declare -i ptest
for ((ptest = 1; ptest <= pass; ptest++)); do
	declare -n ptestref=pass_${ptest}
	if ! sed "${ptestref[@]}" </dev/null &>/dev/null; then
		pass=1
		pass_1=("${pass_all[@]}")
		exps=("${exps[*]}")
		break
	fi
done

# use stdin if pointing to a real file
stdin=$(readlink -m /proc/self/fd/0) || die
if [[ -f ${stdin} ]]; then
	if ${inplace}; then
		# `sed -i < file` doesn't make sense, warn about it
		# unfortunately, also checking for `sed -i > file` is unreliable
		sed-qa 131 "the following uses -i with stdin"
		end
	fi

	(( ${#in[@]} )) && end # unhandled: sed file1 < file2

	in=("${stdin}")
else
	stdin=
fi

(( ${#in[@]} )) || end # no files, or unhandled: command | sed

# check that all files are readable now for clearer errors
for file in "${in[@]}"; do
	[[ -f ${file} && -r ${file} ]] || die "not a readable file: ${file}"
done

# run `sed | sed | ...` passes for each file+expressions that generate a
# cksum for each using tee and redirections to avoid temporary files, then
# unset exps that caused changes (not attempting to modify the final files
# here for safety, will run the intended single sed command for this)
sed-pass() {
	if (( ${1} <= pass )); then
		local -n ref=pass_${1} #!SC2178
		tee >(cksum >&3) | sed "${ref[@]}" | sed-pass $((${1} + 1)) || return 1
	else
		cksum >&3 || return 1
	fi
}
sed-unset_used_exps() {
	local -a sums

	sed-pass 1 3>&1 | map sums || return 101

	(( ${#sums[@]} == pass + 1 )) || return 102 # shouldn't happen

	local -i i
	for ((i=1; i<${#sums[@]}; i++)); do
		[[ ${sums[i]} && ${sums[i-1]} ]] || return 103 # no cksum output?
		[[ ${sums[i]} == "${sums[i-1]}" ]] || unset "exps[i-1]" # did changes
	done
}

if ${inplace} || (( ${#in[@]} == 1 )); then
	for file in "${in[@]}"; do
		(( ${#exps[@]} )) || break
		sed-unset_used_exps < "${file}" || die
	done
else
	# use sed to concat for ensuring done as expected with EOF newlines
	sed -n -e p "${in[@]}" | sed-unset_used_exps || die
fi

if (( ${#exps[@]} )); then
	if (( ${#exps[@]} == pass )); then
		sed-qa 129 "the following did not cause any changes"
	else
		sed-qa 130 "some expressions in the following did not cause any changes"
	fi
	msg "${exps[@]/#/no-op: }"
fi

end # run intended sed command normally

# vim: ts=4
