diff --git a/cmd/glint/main.go b/cmd/glint/main.go index 764db8c..4a318a5 100644 --- a/cmd/glint/main.go +++ b/cmd/glint/main.go @@ -420,9 +420,7 @@ Examples: case "github": writeGitHub(os.Stdout, findings) default: // "text" - for _, f := range findings { - fmt.Println(f) - } + writeTextFindings(os.Stdout, findings) } if len(findings) == 0 { diff --git a/cmd/glint/output.go b/cmd/glint/output.go new file mode 100644 index 0000000..82597a8 --- /dev/null +++ b/cmd/glint/output.go @@ -0,0 +1,111 @@ +package main + +import ( + "fmt" + "io" + "os" + "strings" + + "git.k3nny.fr/glint/internal/linter" +) + +// ANSI escape sequences used for colorized output. +const ( + ansiReset = "\033[0m" + ansiBold = "\033[1m" + ansiDim = "\033[2m" + ansiRed = "\033[31m" + ansiOrange = "\033[33m" // rendered as orange/amber in most terminals +) + +// colorEnabled reports whether ANSI color should be used when writing to w. +// Colors are suppressed when NO_COLOR is set or when w is not a terminal. +func colorEnabled(w io.Writer) bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + f, ok := w.(*os.File) + if !ok { + return false + } + fi, err := f.Stat() + return err == nil && (fi.Mode()&os.ModeCharDevice) != 0 +} + +// writeTextFindings prints findings in a columnized format with optional +// ANSI color. All findings are scanned first to compute column widths so +// that each field aligns across all output lines. +// +// Output columns (space-separated, no borders): +// +// location RULE severity message +func writeTextFindings(w io.Writer, findings []linter.Finding) { + if len(findings) == 0 { + return + } + + color := colorEnabled(w) + + // Pre-compute locations and maximum location width. + locs := make([]string, len(findings)) + maxLoc := 0 + for i, f := range findings { + locs[i] = findingLocation(f) + if len(locs[i]) > maxLoc { + maxLoc = len(locs[i]) + } + } + + // Rule IDs are always 5 chars (GL001–GL999). + const ruleWidth = 5 + // Severity width: "warning" = 7 chars. + const sevWidth = 7 + + for i, f := range findings { + loc := locs[i] + rule := f.Rule + sev := strings.ToLower(string(f.Severity)) + + msg := f.Message + if f.Job != "" { + msg = fmt.Sprintf("job %q: %s", f.Job, msg) + } + + if color { + var sevSeq string + if f.Severity == linter.Error { + sevSeq = ansiRed + ansiBold + } else { + sevSeq = ansiOrange + ansiBold + } + fmt.Fprintf(w, "%s%-*s%s %s%-*s%s %s%-*s%s %s\n", + ansiDim, maxLoc, loc, ansiReset, + ansiBold, ruleWidth, rule, ansiReset, + sevSeq, sevWidth, sev, ansiReset, + msg, + ) + } else { + fmt.Fprintf(w, "%-*s %-*s %-*s %s\n", + maxLoc, loc, + ruleWidth, rule, + sevWidth, sev, + msg, + ) + } + } +} + +// findingLocation formats the file:line:col location string for a finding. +func findingLocation(f linter.Finding) string { + if f.File == "" { + return "" + } + switch { + case f.Line > 0 && f.Column > 0: + return fmt.Sprintf("%s:%d:%d", f.File, f.Line, f.Column) + case f.Line > 0: + return fmt.Sprintf("%s:%d", f.File, f.Line) + default: + return f.File + } +} diff --git a/internal/linter/dependencies.go b/internal/linter/dependencies.go index 888d664..8c18606 100644 --- a/internal/linter/dependencies.go +++ b/internal/linter/dependencies.go @@ -30,6 +30,7 @@ func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding { Job: name, File: job.File, Line: job.Line, + Column: job.Column, Message: fmt.Sprintf("'dependencies' references unknown job %q", dep), }) continue @@ -43,6 +44,7 @@ func checkDependencies(p *model.Pipeline, skipped map[string]bool) []Finding { Job: name, File: job.File, Line: job.Line, + Column: job.Column, Message: fmt.Sprintf("'dependencies' job %q must be in an earlier stage (in %q, current job is in %q)", dep, depJob.Stage, job.Stage), }) } diff --git a/internal/linter/if_reachability.go b/internal/linter/if_reachability.go index 556eefa..c81e6bb 100644 --- a/internal/linter/if_reachability.go +++ b/internal/linter/if_reachability.go @@ -99,6 +99,7 @@ func evalRulesReachability(name string, job model.Job, jobVars map[string]string Job: name, File: job.File, Line: job.Line, + Column: job.Column, Message: "rules: block can never activate: all if: conditions evaluate to false given the declared pipeline variables", } } diff --git a/internal/linter/inherit_completeness.go b/internal/linter/inherit_completeness.go index 9cb72dd..fd79405 100644 --- a/internal/linter/inherit_completeness.go +++ b/internal/linter/inherit_completeness.go @@ -40,6 +40,7 @@ func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job) Job: name, File: job.File, Line: job.Line, + Column: job.Column, Message: "'inherit: default:' is declared but the pipeline has no 'default:' block — declaration has no effect", }} } @@ -70,6 +71,7 @@ func checkJobInheritCompleteness(p *model.Pipeline, name string, job model.Job) Job: name, File: job.File, Line: job.Line, + Column: job.Column, Message: fmt.Sprintf( "'inherit: default: [%s]': %s not defined in the 'default:' block — %s", strings.Join(dead, ", "), diff --git a/internal/linter/linter.go b/internal/linter/linter.go index b14a0c6..8d88144 100644 --- a/internal/linter/linter.go +++ b/internal/linter/linter.go @@ -22,15 +22,19 @@ type Finding struct { Job string // empty for pipeline-level findings File string // source file where the finding originates Line int // line number in File (0 = unknown) + Column int // column number in File (0 = unknown; 1-indexed) Message string } func (f Finding) String() string { var loc string if f.File != "" { - if f.Line > 0 { + switch { + case f.Line > 0 && f.Column > 0: + loc = fmt.Sprintf("%s:%d:%d: ", f.File, f.Line, f.Column) + case f.Line > 0: loc = fmt.Sprintf("%s:%d: ", f.File, f.Line) - } else { + default: loc = fmt.Sprintf("%s: ", f.File) } } @@ -225,6 +229,7 @@ func checkJob(name string, job model.Job, stageSet map[string]bool) []Finding { if findings[i].Job != "" && findings[i].File == "" { findings[i].File = job.File findings[i].Line = job.Line + findings[i].Column = job.Column } } return findings diff --git a/internal/linter/needs.go b/internal/linter/needs.go index 62f0e64..bd98db2 100644 --- a/internal/linter/needs.go +++ b/internal/linter/needs.go @@ -53,6 +53,7 @@ func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding { Job: name, File: job.File, Line: job.Line, + Column: job.Column, Message: fmt.Sprintf("needs unknown job %q", entry.job), }) continue @@ -71,6 +72,7 @@ func checkNeeds(p *model.Pipeline, skipped map[string]bool) []Finding { Job: name, File: job.File, Line: job.Line, + Column: job.Column, Message: fmt.Sprintf( "needs %q which is in a later stage (%q after %q)", entry.job, neededJob.Stage, job.Stage, @@ -111,6 +113,7 @@ func checkRulesNeeds(p *model.Pipeline, skipped map[string]bool) []Finding { Job: name, File: job.File, Line: job.Line, + Column: job.Column, Message: fmt.Sprintf( "rules[%d].needs: references unknown job %q", i, entry.job, @@ -171,6 +174,7 @@ func detectNeedsCycles(graph map[string][]string, jobs map[string]model.Job) []F Job: name, File: j.File, Line: j.Line, + Column: j.Column, Message: fmt.Sprintf("circular dependency in needs: %v → %s", path, name), }) } diff --git a/internal/linter/variables.go b/internal/linter/variables.go index d89bac6..b5bdd3e 100644 --- a/internal/linter/variables.go +++ b/internal/linter/variables.go @@ -148,6 +148,7 @@ func checkVariableRefs(p *model.Pipeline) []Finding { Job: name, File: job.File, Line: job.Line, + Column: job.Column, Message: fmt.Sprintf("rules[%d].if: $%s is not declared in pipeline or job variables:", i, varName), }) } diff --git a/internal/model/parser.go b/internal/model/parser.go index dc2255e..316acac 100644 --- a/internal/model/parser.go +++ b/internal/model/parser.go @@ -83,7 +83,8 @@ func ParseBytes(data []byte) (*Pipeline, error) { return nil, fmt.Errorf("parsing job %q: %w", key, err) } j.Name = key - j.Line = keyNode.Line // exact line of the job name key + j.Line = keyNode.Line // exact line of the job name key + j.Column = keyNode.Column // exact column of the job name key p.Jobs[key] = j } diff --git a/internal/model/pipeline.go b/internal/model/pipeline.go index eec44b1..d4f55e5 100644 --- a/internal/model/pipeline.go +++ b/internal/model/pipeline.go @@ -45,9 +45,10 @@ type Workflow struct { } type Job struct { - Name string // set by parser, not from YAML - File string // source file; set by Parse / resolver - Line int // line of the job key in its source file; set by parser + Name string // set by parser, not from YAML + File string // source file; set by Parse / resolver + Line int // line of the job key in its source file; set by parser + Column int // column of the job key (1-indexed); set by parser Stage string `yaml:"stage"` Script any `yaml:"script"` // []string or string (block scalar) Run any `yaml:"run"` // alternative to script (CI steps)