diff --git a/rc/tools/lint.kak b/rc/tools/lint.kak index a0accd08..3774c365 100644 --- a/rc/tools/lint.kak +++ b/rc/tools/lint.kak @@ -1,113 +1,327 @@ -declare-option -docstring %{ - shell command to which the path of a copy of the current buffer will be passed - The output returned by this command is expected to comply with the following format: - {filename}:{line}:{column}: {kind}: {message} -} str lintcmd +declare-option \ + -docstring %{ + The shell command used by lint-buffer and lint-selections. + + It will be given the path to a file containing the text to be + linted, and must produce output in the format: + + {filename}:{line}:{column}: {kind}: {message} + + If the 'kind' field contains 'error', the message is treated + as an error, otherwise it is assumed to be a warning. + } \ + str lintcmd declare-option -hidden line-specs lint_flags -declare-option -hidden range-specs lint_errors +declare-option -hidden line-specs lint_messages declare-option -hidden int lint_error_count declare-option -hidden int lint_warning_count -define-command lint -docstring 'Parse the current buffer with a linter' %{ - evaluate-commands %sh{ - if [ -z "${kak_opt_lintcmd}" ]; then - echo 'fail The `lintcmd` option is not set' - exit 1 - fi +define-command \ + -hidden \ + -params 1 \ + -docstring %{ + lint-cleaned-selections : Check each selection with . + Assumes selections all have anchor before cursor, and that + %val{selections} and %val{selections_desc} are in the same order. + } \ + lint-cleaned-selections \ +%{ + # Clear the current contents of the various options. + set-option buffer lint_flags %val{timestamp} + set-option buffer lint_messages %val{timestamp} + set-option buffer lint_error_count 0 + set-option buffer lint_warning_count 0 + + # Create a temporary directory to keep all our state. + evaluate-commands %sh{ + # This is going to come in handy later. + kakquote() { printf "%s" "$*" | sed "s/'/''/g; 1s/^/'/; \$s/\$/'/"; } + + # Before we clobber our arguments, + # let's record the lintcmd we were given. + lintcmd="$1" + + # Some linters care about the name or extension + # of the file being linted, so we'll store the text we want to lint + # in a file with the same name as the original buffer. filename="${kak_buffile##*/}" + # A directory to keep all our temporary data. dir=$(mktemp -d "${TMPDIR:-/tmp}"/kak-lint.XXXXXXXX) - mkfifo "$dir"/fifo - printf '%s\n' "evaluate-commands -no-hooks write -sync $dir/${filename}" + # A fifo to send the results back to a Kakoune buffer. + # FIXME: Should we put the lint output in toolsclient? + mkfifo "$dir"/fifo printf '%s\n' "evaluate-commands -draft %{ - edit! -fifo $dir/fifo -debug *lint-output* + edit! -fifo $(kakquote "$dir/fifo") -debug *lint-output* set-option buffer filetype make set-option buffer make_current_error_line 0 - hook -always -once buffer BufCloseFifo .* %{ nop %sh{ rm -r '$dir' } } }" + # Write all the selection descriptions to files. + eval set -- "$kak_selections_desc" + i=0 + for desc; do + mkdir -p "$dir"/sel-"$i" + printf "%s" "$desc" > "$dir"/sel-$i/desc + i=$(( i + 1 )) + done + + # Write all the selection contents to files. + eval set -- "$kak_quoted_selections" + i=0 + for text; do + # The selection text needs to be stored in a subdirectory, + # so we can be sure the filename won't clash with one of ours. + mkdir -p "$dir"/sel-"$i"/text/ + printf "%s" "$text" > "$dir"/sel-$i/text/"$filename" + i=$(( i + 1 )) + done + + # We do redirection trickiness to record stderr from + # this background task and route it back to Kakoune, + # but shellcheck isn't a fan. + # shellcheck disable=SC2094 ({ # do the parsing in the background and when ready send to the session - eval "$kak_opt_lintcmd '$dir'/${filename}" | sort -t: -k2,2 -n > "$dir"/stderr + for selpath in "$dir"/sel-*; do + # Read in the line and column offset of this selection. + IFS=".," read -r start_line start_byte _ < "$selpath"/desc + + # Run the linter, and record the exit-code. + eval "$lintcmd '$selpath/text/$filename'" | + sort -t: -k2,2 -n | + awk \ + -v line_offset=$(( start_line - 1 )) \ + -v first_line_byte_offset=$(( start_byte - 1 )) \ + ' + BEGIN { OFS=":"; FS=":" } + + /:[1-9][0-9]*:[1-9][0-9]*:/ { + $1 = ENVIRON["kak_bufname"] + if ( $2 == 1 ) { + $3 += first_line_byte_offset + } + $2 += line_offset + print $0 + } + ' >>"$dir"/result + done + + # Load all the linter messages into Kakoune options. + # Inside this block, shellcheck warns us that the shell doesn't + # need backslash-continuation chars in a single-quoted string, + # but awk still needs them. + # shellcheck disable=SC1004 + awk -v file="$kak_buffile" -v client="$kak_client" ' + function kakquote(text) { + # \x27 is apostrophe, escaped for shell-quoting reasons. + gsub(/\x27/, "\x27\x27", text) + return "\x27" text "\x27" + } - # Flags for the gutter: - # stamp l3|{red}█ l11|{yellow}█ - # Contextual error messages: - # stamp 'l1.c1,l1.c1|kind:message' 'l2.c2,l2.c2|kind:message' - awk -F: -v file="$kak_buffile" -v stamp="$kak_timestamp" -v client="$kak_client" ' BEGIN { + OFS=":" + FS=":" error_count = 0 warning_count = 0 } - /:[1-9][0-9]*:[1-9][0-9]*: ([Ff]atal )?[Ee]rror/ { - flags = flags " " $2 "|{red}█" - error_count++ - } + /:[1-9][0-9]*:[1-9][0-9]*:/ { - if ($4 !~ /[Ee]rror/) { - flags = flags " " $2 "|{yellow}█" + # Remember that an error or a warning occurs on this line.. + if ($4 ~ /[Ee]rror/) { + # We definitely have an error on this line. + flags_by_line[$2] = "{Error}x" + error_count++ + } else if (flags_by_line[$2] ~ /Error/) { + # We have a warning on this line, + # but we already have an error, so do nothing. + warning_count++ + } else { + # We have a warning on this line, + # and no previous error. + flags_by_line[$2] = "{Information}!" warning_count++ } - } - /:[1-9][0-9]*:[1-9][0-9]*:/ { - kind = substr($4, 2) - error = $2 "." $3 "," $2 "." $3 "|" kind - msg = "" - # fix case where $5 is not the last field because of extra colons in the message + + # The message starts with the severity indicator. + msg = substr($4, 2) + + # fix case where $5 is not the last field + # because of extra colons in the message for (i=5; i<=NF; i++) msg = msg ":" $i + + # Mention the column where this problem occurs, + # so that information is not lost. + msg = msg "(col " $3 ")" + + # Messages will be stored in a line-specs option, + # and each record in the option uses "|" + # as a field delimiter, so we need to escape them. gsub(/\|/, "\\|", msg) - gsub("'\''", "'"''"'", msg) - error = error msg " (col " $3 ")" - errors = errors " '\''" error "'\''" + + if ($2 in messages_by_line) { + # We already have a message on this line, + # so append our new message. + messages_by_line[$2] = messages_by_line[$2] "\n" msg + } else { + # A brand-new message on this line. + messages_by_line[$2] = msg + } } + END { - print "set-option \"buffer=" file "\" lint_flags " stamp flags - gsub("~", "\\~", errors) - print "set-option \"buffer=" file "\" lint_errors " stamp errors - print "set-option \"buffer=" file "\" lint_error_count " error_count - print "set-option \"buffer=" file "\" lint_warning_count " warning_count - print "evaluate-commands -client " client " lint-show-counters" - } - ' "$dir"/stderr | kak -p "$kak_session" + for (line in flags_by_line) { + flag = flags_by_line[line] - cut -d: -f2- "$dir"/stderr | awk -v bufname="${kak_bufname}" ' - /^[1-9][0-9]*:[1-9][0-9]*:/ { - print bufname ":" $0 - } - ' > "$dir"/fifo + print "set-option -add " \ + kakquote("buffer=" file) " " \ + "lint_flags " \ + kakquote(line "|" flag) + } - } & ) >/dev/null 2>&1 >>\n" + # FIXME: When #3254 is fixed, this can become a "fail" + printf "eval -client %s echo -markup {Error}%s\n" \ + "$kak_client" \ + "lint failed, see *debug* for details" + else + # No errors detected, show the results. + printf "eval -client %s lint-show-counters" \ + "$kak_client" + fi | kak -p "$kak_session" + + # We are done here. Send the results to Kakoune, + # and clean up. + cat "$dir"/result > "$dir"/fifo + rm -rf "$dir" + + } & ) >"$dir"/stderr 2>&1 ]: Check each selection with a linter. + + Switches: + -command Use the given linter. + If not given, the lintcmd option is used. + } \ + lint-selections \ +%{ + evaluate-commands -draft %{ + # Make sure all the selections are "forward" (anchor before cursor) + execute-keys + + # Make sure the selections are in document order. + evaluate-commands %sh{ + printf "select " + printf "%s\n" "$kak_selections_desc" | + tr ' ' '\n' | + sort -n -t. | + tr '\n' ' ' + } + + evaluate-commands %sh{ + # This is going to come in handy later. + kakquote() { printf "%s" "$*" | sed "s/'/''/g; 1s/^/'/; \$s/\$/'/"; } + + if [ "$1" = "-command" ]; then + if [ -z "$2" ]; then + echo 'fail -- -command option requires a value' + exit 1 + fi + lintcmd="$2" + elif [ -n "$1" ]; then + echo "fail -- Unrecognised parameter $(kakquote "$1")" + exit 1 + elif [ -z "${kak_opt_lintcmd}" ]; then + echo 'fail The lintcmd option is not set' + exit 1 + else + lintcmd="$kak_opt_lintcmd" + fi + + printf 'lint-cleaned-selections %s\n' "$(kakquote "$lintcmd")" + } + } +} + +define-command \ + -docstring %{ + lint-buffer: Check the current buffer with a linter. + + Set the lintcmd option to control which linter is used. + } \ + lint-buffer \ +%{ + evaluate-commands -draft %{ + execute-keys '%' + lint-cleaned-selections %opt{lintcmd} + } +} + +alias global lint lint-buffer + define-command -hidden lint-show %{ - update-option buffer lint_errors + update-option buffer lint_messages evaluate-commands %sh{ - eval "set -- ${kak_quoted_opt_lint_errors}" - shift + # This is going to come in handy later. + kakquote() { printf "%s" "$*" | sed "s/'/''/g; 1s/^/'/; \$s/\$/'/"; } - s="" - for i in "$@"; do - s="${s} -${i}" + eval set -- "${kak_quoted_opt_lint_messages}" + shift # skip the timestamp + + while [ $# -gt 0 ]; do + lineno=${1%%|*} + msg=${1#*|} + + if [ "$lineno" -eq "$kak_cursor_line" ]; then + printf "info -anchor %d.%d %s\n" \ + "$kak_cursor_line" \ + "$kak_cursor_column" \ + "$(kakquote "$msg")" + break + fi + shift done - - printf %s\\n "${s}" | awk -v line="${kak_cursor_line}" \ - -v column="${kak_cursor_column}" \ - "/^${kak_cursor_line}\./"' { - gsub(/"|%/, "&&") - msg = substr($0, index($0, "|")) - sub(/^[^ \t]+[ \t]+/, "", msg) - printf "info -anchor %d.%d \"%s\"\n", line, column, msg - }' } } define-command -hidden lint-show-counters %{ - echo -markup linting results:{red} %opt{lint_error_count} error(s){yellow} %opt{lint_warning_count} warning(s) + echo -markup "linting results: {Error} %opt{lint_error_count} error(s) {Information} %opt{lint_warning_count} warning(s) " } define-command lint-enable -docstring "Activate automatic diagnostics of the code" %{ @@ -121,54 +335,117 @@ define-command lint-disable -docstring "Disable automatic diagnostics of the cod remove-hooks window lint-diagnostics } -define-command lint-next-error -docstring "Jump to the next line that contains an error" %{ - update-option buffer lint_errors +# FIXME: Is there some way we can re-use make-next-error +# instead of re-implementing it? +define-command \ + -docstring "Jump to the next line that contains a lint message" \ + lint-next-message \ +%{ + update-option buffer lint_messages evaluate-commands %sh{ - eval "set -- ${kak_quoted_opt_lint_errors}" + # This is going to come in handy later. + kakquote() { printf "%s" "$*" | sed "s/'/''/g; 1s/^/'/; \$s/\$/'/"; } + + eval "set -- ${kak_quoted_opt_lint_messages}" shift - for i in "$@"; do - candidate="${i%%|*}" - if [ "${candidate%%.*}" -gt "${kak_cursor_line}" ]; then - range="${candidate}" - break + if [ "$#" -eq 0 ]; then + printf 'fail no lint messages' + exit + fi + + first_lineno="" + first_msg="" + + for lint_message; do + lineno="${lint_message%%|*}" + msg="${lint_message#*|}" + + if [ -z "$first_lineno" ]; then + first_lineno=$lineno + first_msg=$msg + fi + + if [ "$lineno" -gt "$kak_cursor_line" ]; then + printf "execute-keys %dg\n" "$lineno" + printf "info -anchor %d.%d %s\n" \ + "$lineno" "1" "$(kakquote "$msg")" + exit fi done - range="${range-${1%%|*}}" - if [ -n "${range}" ]; then - printf 'select %s\n' "${range}" - else - echo 'fail no lint diagnostics' - fi + # We didn't find any messages after the current line, + # let's wrap around to the beginning. + printf "execute-keys %dg\n" "$first_lineno" + printf "info -anchor %d.%d %s\n" \ + "$first_lineno" "1" "$(kakquote "$first_msg")" + printf "echo -markup \ + {Information}lint message search wrapped around buffer\n" + } } -define-command lint-previous-error -docstring "Jump to the previous line that contains an error" %{ - update-option buffer lint_errors +# FIXME: Is there some way we can re-use make-previous-error +# instead of re-implementing it? +define-command \ + -docstring "Jump to the previous line that contains a lint message" \ + lint-previous-message \ +%{ + update-option buffer lint_messages evaluate-commands %sh{ - eval "set -- ${kak_quoted_opt_lint_errors}" + # This is going to come in handy later. + kakquote() { printf "%s" "$*" | sed "s/'/''/g; 1s/^/'/; \$s/\$/'/"; } + + eval "set -- ${kak_quoted_opt_lint_messages}" shift - for i in "$@"; do - candidate="${i%%|*}" + if [ "$#" -eq 0 ]; then + printf 'fail no lint messages' + exit + fi - if [ "${candidate%%.*}" -ge "${kak_cursor_line}" ]; then - range="${last_candidate}" - break + prev_lineno="" + prev_msg="" + + for lint_message; do + lineno="${lint_message%%|*}" + msg="${lint_message#*|}" + + # If this message comes on or after the cursor position... + if [ "$lineno" -ge "${kak_cursor_line}" ]; then + # ...and we had a previous message... + if [ -n "$prev_lineno" ]; then + # ...then go to the previous message and display it. + printf "execute-keys %dg\n" "$prev_lineno" + printf "info -anchor %d.%d %s\n" \ + "$lineno" "1" "$(kakquote "$prev_msg")" + exit + + # We are after the cursor position, but there has been + # no previous message; we'll need to do something else. + else + break + fi fi - last_candidate="${candidate}" + # We have not yet reached the cursor position, stash this message + # and try the next. + prev_lineno="$lineno" + prev_msg="$msg" done - if [ $# -ge 1 ]; then - shift $(($# - 1)) - range="${range:-${1%%|*}}" - printf 'select %s\n' "${range}" - else - echo 'fail no lint diagnostics' - fi + # There is no message before the cursor position, + # let's wrap around to the end. + shift $(( $# - 1 )) + last_lineno="${1%%|*}" + last_msg="${1#*|}" + + printf "execute-keys %dg\n" "$last_lineno" + printf "info -anchor %d.%d %s\n" \ + "$last_lineno" "1" "$(kakquote "$last_msg")" + printf "echo -markup \ + {Information}lint message search wrapped around buffer\n" } }