Something went wrong. Try again.
A Thrift language server, formatter, and linter.
lsp linter helix vim vscode thrift
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278// Package check runs the language server's diagnostic pipeline over files// without an editor: the high-level boundary for non-LSP frontends.// Config discovery belongs to the caller — Run takes resolved inputs —// and so does presentation: results are data, printing is the CLI's job.package check
import ( "context" "fmt" "os" "path/filepath"
"go.lsp.dev/uri"
"github.com/karitham/thrift-ls/analyzers" "github.com/karitham/thrift-ls/options" "github.com/karitham/thrift-ls/sema" "github.com/karitham/thrift-ls/store" "github.com/karitham/thrift-ls/syntax")
// Request is one check run over materialized files.type Request struct { // Files are the absolute thrift paths to check. Files []string // Folder is the absolute resolution root. Empty derives from the // first file's directory. Folder string // IncludePaths are the compiler-equivalent include roots, authoritative // for the run. IncludePaths []string // Lint tunes the pipeline. Nil means defaults. Lint *options.LintConfig // Analyzers are appended to the built-in pipeline. Analyzers []sema.Analyzer // Fix rewrites files in place until nothing applies, preserving file // permissions. Fix bool}
// Severity is a diagnostic's display weight.type Severity string
const ( SeverityError Severity = "error" SeverityWarning Severity = "warning" SeverityInfo Severity = "info" SeverityHint Severity = "hint")
// Diagnostic is one finding in 1-based file coordinates with UTF-16// columns: ready to print, with no editor-protocol vocabulary.type Diagnostic struct { Line, Col int EndLine, EndCol int Severity Severity Code string Message string}
// SkippedFix is a fix that could not apply.type SkippedFix struct { // File is the absolute path of the file carrying the fix. File string // Title names the fix. Title string // Reason explains why it was skipped. Reason string}
// FixSummary reports a fix run.type FixSummary struct { // Applied is the number of fixes applied across all passes. Applied int // Files lists the fixed files' absolute paths. Files []string // Passes is the number of pipeline runs. Passes int // Skipped lists the fixes that could not apply. Skipped []SkippedFix}
// Result is one check run. Diagnostics are keyed by absolute path.// Without Fix they hold everything found; with Fix they hold what remains// unfixed. Fix is nil unless Request.Fix.type Result struct { Diagnostics map[string][]Diagnostic Fix *FixSummary}
// Run checks req.Files through the same store and checker pipeline the LSP// uses, and returns the diagnostics per file. It exits no process and// prints nothing; severity gating (failing CI on errors) belongs to the// caller.func Run(ctx context.Context, req Request) (Result, error) { folder := req.Folder if folder == "" { if len(req.Files) == 0 { return Result{Diagnostics: map[string][]Diagnostic{}}, nil }
folder = filepath.Dir(req.Files[0]) }
lint := sema.Config{} if req.Lint != nil { var disabled []string if req.Lint.Disabled != nil { disabled = *req.Lint.Disabled }
var severity map[string]string if req.Lint.Severity != nil { severity = *req.Lint.Severity }
lint = sema.ConfigFromLint(disabled, severity) }
if req.Fix { return runFix(ctx, req.Files, folder, req.IncludePaths, lint, req.Analyzers) }
diags, err := runDiagnostics(ctx, req.Files, folder, req.IncludePaths, lint, req.Analyzers) if err != nil { return Result{}, err }
return Result{Diagnostics: diags}, nil}
// runDiagnostics runs the language server's diagnostic pipeline — parse,// semantic analysis, and lints — over files opened in a session rooted at// folder, and returns the diagnostics per file, keyed by absolute path.func runDiagnostics(ctx context.Context, files []string, folder string, includePaths []string, lint sema.Config, extra []sema.Analyzer) (map[string][]Diagnostic, error) { _, view, uris := store.OpenDisk(files, folder, includePaths)
out := make(map[string][]Diagnostic, len(files))
// One pipeline run over the whole corpus: the shared index memoizes // resolutions across files, so each name resolves once. report, err := analyzers.DefaultPipeline(lint).WithAnalyzers(extra...).Run(ctx, view, uris) if err != nil { return nil, err }
for i := range files { diags, err := toDiagnostics(ctx, view, uris[i], report[uris[i]]) if err != nil { return nil, err }
out[files[i]] = diags }
return out, nil}
// runFix applies the diagnostics' fixes to the checked files and reports// what remains. Only the requested files are fixed — one file, or one// folder — while resolution reads the whole view, so fixing a greenfield// module resolves its types against the tree without touching the tree.func runFix(ctx context.Context, files []string, folder string, includePaths []string, lint sema.Config, extra []sema.Analyzer) (Result, error) { _, view, uris := store.OpenDisk(files, folder, includePaths)
// Reads hit the disk, so the next pass re-parses what was written // with no overlay round-trip. persist := func(ctx context.Context, u uri.URI, content []byte) error { if err := ctx.Err(); err != nil { return fmt.Errorf("check canceled: %w", err) }
perms := os.FileMode(0o644) if info, statErr := os.Stat(u.FsPath()); statErr == nil { perms = info.Mode() }
return os.WriteFile(u.FsPath(), content, perms) }
res, err := analyzers.DefaultPipeline(lint).WithAnalyzers(extra...).FixAll(ctx, view, uris, persist)
summary := &FixSummary{ Applied: res.Applied, Passes: res.Passes, Files: make([]string, 0, len(res.FixedFiles)), Skipped: make([]SkippedFix, 0, len(res.Skipped)), }
for _, u := range res.FixedFiles { summary.Files = append(summary.Files, u.FsPath()) }
for _, s := range res.Skipped { summary.Skipped = append(summary.Skipped, SkippedFix{ File: s.File.FsPath(), Title: s.Fix.Title, Reason: s.Reason, }) }
if err != nil { if res.Applied == 0 && len(res.FixedFiles) == 0 { return Result{}, err }
return Result{Fix: summary}, err }
remaining := make(map[string][]Diagnostic, len(files))
for i := range files { diags, err := toDiagnostics(ctx, view, uris[i], res.Remaining[uris[i]]) if err != nil { return Result{}, err }
remaining[files[i]] = diags }
return Result{Diagnostics: remaining, Fix: summary}, nil}
// toDiagnostics translates one file's pipeline findings into printable// diagnostics: spans map through the file's mapper to 1-based UTF-16// columns.func toDiagnostics(ctx context.Context, view *store.View, file uri.URI, diags []sema.Diagnostic) ([]Diagnostic, error) { pf, err := view.Parse(ctx, file) if err != nil { return nil, err }
out := make([]Diagnostic, len(diags)) for i, d := range diags { line, col := position(pf, d.Span.Start) endLine, endCol := position(pf, d.Span.End) out[i] = Diagnostic{ Line: line, Col: col, EndLine: endLine, EndCol: endCol, Severity: toSeverity(d.Severity), Code: d.Code, Message: d.Message, } }
return out, nil}
// position maps a parser offset to 1-based file coordinates with UTF-16// columns. When the offset does not map, the parser's own 1-based// coordinates pass through.func position(pf *store.ParsedFile, pos syntax.Position) (line, col int) { p, err := pf.Mapper().OffsetToLSPPosition(pos.Offset) if err != nil { return pos.Line, pos.Col }
return int(p.Line) + 1, int(p.Character) + 1}
// toSeverity maps the pipeline scale onto the display scale.func toSeverity(s sema.Severity) Severity { switch s { case sema.SeverityError: return SeverityError case sema.SeverityWarning: return SeverityWarning case sema.SeverityInfo: return SeverityInfo case sema.SeverityHint: return SeverityHint default: return SeverityWarning }}