internal/platform/msg/output.go (233 lines of code) (raw):
/*
* Copyright 2021-2024 JetBrains s.r.o.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package msg
import (
"fmt"
"os"
"strings"
"github.com/JetBrains/qodana-cli/internal/platform/qdenv"
cienvironment "github.com/cucumber/ci-environment/go"
"github.com/liamg/clinch/terminal"
"github.com/mattn/go-isatty"
"github.com/pterm/pterm"
log "github.com/sirupsen/logrus"
)
var QodanaInteractiveSelect = pterm.InteractiveSelectPrinter{
TextStyle: PrimaryStyle,
DefaultText: "Please select the product to use",
Options: []string{},
OptionStyle: PrimaryStyle,
DefaultOption: "",
MaxHeight: 5,
Selector: ">",
SelectorStyle: PrimaryStyle,
}
// InfoString Two newlines at the start are important to lay the output nicely in CLI.
func InfoString(version string) string {
return fmt.Sprintf(
`
%s (%s)
https://jb.gg/qodana-cli
Documentation – https://jb.gg/qodana-docs
Contact us at qodana-support@jetbrains.com
Bug Tracker: https://jb.gg/qodana-issue
Community forum: https://jb.gg/qodana-forum
`, "Qodana CLI", version,
)
}
// IsInteractive returns true if the current execution environment is interactive (useful for colors/animations toggle).
func IsInteractive() bool {
return !qdenv.IsContainer() && os.Getenv("NONINTERACTIVE") == "" && (isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()))
}
// DisableColor disables colors in the output.
func DisableColor() {
pterm.DisableColor()
}
// styles and different declarations intended to be used only inside this file
var (
noLineWidth = 7
QodanaSpinner = pterm.DefaultSpinner
spinnerSequence = []string{"| ", "/ ", "- ", "\\ "}
PrimaryStyle = pterm.NewStyle() // PrimaryStyle is a primary text style.
primaryBoldStyle = pterm.NewStyle(pterm.Bold) // primaryBoldStyle is a Primary bold text style.
errorStyle = pterm.NewStyle(pterm.FgRed) // errorStyle is an error style.
warningStyle = pterm.NewStyle(pterm.FgYellow) // warningStyle is a warning style.
miscStyle = pterm.NewStyle(pterm.FgGray) // miscStyle is a log style.
tableSep = miscStyle.Sprint("─")
tableSepUp = miscStyle.Sprint("┬")
tableSepMid = miscStyle.Sprint("│")
tableSepDown = miscStyle.Sprint("┴")
tableUp = strings.Repeat(tableSep, noLineWidth) + tableSepUp
tableDown = strings.Repeat(tableSep, noLineWidth) + tableSepDown
DefaultPromptText = "Do you want to continue?"
)
// Primary prints a message in the Primary style.
func Primary(text string, a ...interface{}) string {
text = fmt.Sprintf(text, a...)
return PrimaryStyle.Sprint(text)
}
// PrimaryBold prints a message in the primary bold style.
func PrimaryBold(text string, a ...interface{}) string {
text = fmt.Sprintf(text, a...)
return primaryBoldStyle.Sprint(text)
}
// EmptyMessage is a message that is used when there is no message to show.
func EmptyMessage() {
pterm.Println()
}
// SuccessMessage prints a success message with the icon.
func SuccessMessage(message string, a ...interface{}) {
message = fmt.Sprintf(message, a...)
icon := pterm.Green("✓ ")
pterm.Println(icon, Primary(message))
}
// WarningMessage prints a warning message with the icon.
func WarningMessage(message string, a ...interface{}) {
message = fmt.Sprintf(message, a...)
icon := warningStyle.Sprint("\n! ")
pterm.Println(icon, Primary(message))
}
// WarningMessageCI prints a warning message to the CI environment (additional highlighting).
func WarningMessageCI(message string, a ...interface{}) {
message = fmt.Sprintf(message, a...)
pterm.Println(formatMessageForCI("warning", "%s", message))
}
// ErrorMessage prints an error message with the icon.
func ErrorMessage(message string, a ...interface{}) {
message = fmt.Sprintf(message, a...)
icon := errorStyle.Sprint("✗ ")
pterm.Println(icon, errorStyle.Sprint(message))
}
// PrintLinterLog prints the linter logs with color, when needed.
func PrintLinterLog(line string) {
if strings.Contains(line, " / /") ||
strings.Contains(line, "_ _") ||
strings.Contains(line, "\\/__") ||
strings.Contains(line, "\\ \\") {
PrimaryStyle.Println(line)
} else {
miscStyle.Println(line)
}
}
// PrintProcess prints the message for processing phase. TODO: Add ETA based on previous runs
func PrintProcess(f func(spinner *pterm.SpinnerPrinter), start string, finished string) {
if err := spin(f, start); err != nil {
log.Fatal("\nProblem occurred:", err.Error())
}
if finished != "" {
SuccessMessage("Finished %s", finished)
}
}
// spin creates spinner and runs the given function. Also, spin is a spider in Dutch.
func spin(fun func(spinner *pterm.SpinnerPrinter), message string) error {
spinner, _ := StartQodanaSpinner(message)
if spinner == nil {
fmt.Println(Primary(message + "..."))
}
fun(spinner)
if spinner != nil {
spinner.Success()
}
return nil
}
// StartQodanaSpinner starts a new spinner with the given message.
func StartQodanaSpinner(message string) (*pterm.SpinnerPrinter, error) {
if IsInteractive() {
QodanaSpinner.Sequence = spinnerSequence
QodanaSpinner.MessageStyle = PrimaryStyle
return QodanaSpinner.WithStyle(pterm.NewStyle(pterm.FgGray)).WithRemoveWhenDone(true).Start(message + "...")
}
return nil, nil
}
// UpdateText updates the text of the spinner.
func UpdateText(spinner *pterm.SpinnerPrinter, message string) {
if spinner != nil {
spinner.UpdateText(message + "...")
}
}
// PrintFile prints the given file content with lines like printProblem.
func PrintFile(file string) {
printHeader("", "", file)
content, err := os.ReadFile(file)
if err != nil {
log.Fatalf("failed to read file %s: %s", file, err)
}
printLines(string(content), 1, 0, true)
}
// PrintProblem printProblem prints problem with source code or without it.
func PrintProblem(
ruleId string,
level string,
message string,
path string,
line int,
column int,
contextLine int,
context string,
) {
printHeader(level, ruleId, "")
printPath(path, line, column)
printLines(context, contextLine, line, false)
fmt.Print(message + "\n")
}
// getTerminalWidth returns the width of the terminal.
func getTerminalWidth() int {
width, _ := terminal.Size()
if width <= 0 {
width = 80
}
return width
}
// printHeader prints the header of the problem/file.
func printHeader(level string, ruleId string, file string) {
width := getTerminalWidth()
fmt.Printf("%s %s\n", PrimaryBold(strings.ToUpper(level)), Primary(ruleId))
fmt.Println(strings.Repeat(tableSep, width))
if file != "" {
fmt.Printf("%5s %s %s\n", "", tableSepMid, PrimaryBold(file))
fmt.Println(strings.Repeat(tableSep, width))
}
}
// printPath prints the path of the problem.
func printPath(path string, line int, column int) {
if path != "" && line > 0 && column > 0 {
fmt.Printf(" %s:%d:%d\n", path, line, column)
fmt.Printf("%s%s\n", tableUp, strings.Repeat(tableSep, getTerminalWidth()-noLineWidth-1))
} else {
fmt.Println(strings.Repeat(tableSep, getTerminalWidth()))
}
}
// printLines prints the lines of the problem.
func printLines(content string, contextLine int, line int, skipHighlight bool) {
lines := strings.Split(content, "\n")
if lines[len(lines)-1] == "" {
// Remove the last empty line if content ends with a newline
lines = lines[:len(lines)-1]
}
for i := 0; i < len(lines); i++ {
var printLine string
currentLine := contextLine + i
if skipHighlight {
printLine = lines[i]
} else if currentLine == line {
printLine = errorStyle.Sprint(lines[i]) + " ←"
} else {
printLine = warningStyle.Sprint(lines[i])
}
lineNumber := miscStyle.Sprintf("%5d", currentLine)
fmt.Printf("%s %s %s\n", lineNumber, tableSepMid, printLine)
}
fmt.Printf("%s%s\n", tableDown, strings.Repeat(tableSep, getTerminalWidth()-noLineWidth-1))
}
// GetProblemsFoundMessage returns a message about the number of problems found, used in CLI and BitBucket report.
func GetProblemsFoundMessage(newProblems int) string {
switch newProblems {
case 0:
return "It seems all right 👌 No new problems found according to the checks applied"
case 1:
return "Found 1 new problem according to the checks applied"
default:
return fmt.Sprintf("Found %d new problems according to the checks applied", newProblems)
}
}
// formatMessageForCI formats the message for the CI environment.
func formatMessageForCI(level, format string, a ...interface{}) string {
message := fmt.Sprintf(format, a...)
ci := cienvironment.DetectCIEnvironment()
if ci != nil {
name := qdenv.GetCIName(ci)
if name == "github-actions" {
return fmt.Sprintf("::%s::%s", level, message)
} else if strings.HasPrefix(name, "azure") {
return fmt.Sprintf("##vso[task.logissue type=%s]%s", level, message)
} else if strings.HasPrefix(name, "circleci") {
return fmt.Sprintf("echo '%s: %s'", level, message)
}
}
return fmt.Sprintf("! %s", message)
}