Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]

- Fix LSP incorrectly reporting "edition '2024' not yet fully supported" errors.
- Fix CEL compilation error messages in `buf lint` to use the structured error API instead of parsing cel-go's text output.

## [v1.68.1] - 2026-04-14
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ require (
connectrpc.com/connect v1.19.1
connectrpc.com/grpcreflect v1.3.0
connectrpc.com/otelconnect v0.9.0
github.com/bufbuild/protocompile v0.14.2-0.20260414204819-0b1a6cd46bcb
github.com/bufbuild/protocompile v0.14.2-0.20260416105908-db11c79f9322
github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b
github.com/cli/browser v1.3.0
github.com/gofrs/flock v0.13.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/bufbuild/protocompile v0.14.2-0.20260414204819-0b1a6cd46bcb h1:jdS2S8gfhRe0KGzvVbysqIsobREx4dDq9e1hppy4a6A=
github.com/bufbuild/protocompile v0.14.2-0.20260414204819-0b1a6cd46bcb/go.mod h1:DhgqsRznX/F0sGkUYtTQJRP+q8xMReQRQ3qr+n1opWU=
github.com/bufbuild/protocompile v0.14.2-0.20260416105908-db11c79f9322 h1:0jR8G4rU/roKE9WY0Mh72a0t5GegneOgezPNdzqfI1M=
github.com/bufbuild/protocompile v0.14.2-0.20260416105908-db11c79f9322/go.mod h1:DhgqsRznX/F0sGkUYtTQJRP+q8xMReQRQ3qr+n1opWU=
github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b h1:b7wvo9ZhjLzCp7tGbOUMvgtYTnd33zGSAmMxcdxMnhQ=
github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
Expand Down
12 changes: 9 additions & 3 deletions private/buf/buflsp/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -1128,10 +1128,16 @@ func (f *file) RunChecks(ctx context.Context) {
}
path := f.objectInfo.Path()

opener := make(fileOpener)
for path, file := range f.workspace.PathToFile() {
opener[path] = file.file.Text()
// Snapshot the current workspace files into a source.Openers so that the
// image build sees a consistent view of file contents, including any unsaved
// modifications. source.WKTs() provides a fallback for well-known types that
// may not be present in the workspace.
workspaceOpener := source.NewMap(nil)
workspaceFiles := workspaceOpener.Get()
for filePath, workspaceFile := range f.workspace.PathToFile() {
workspaceFiles[filePath] = workspaceFile.file
}
opener := &source.Openers{workspaceOpener, source.WKTs()}

const checkTimeout = 30 * time.Second
ctx, cancel := context.WithTimeout(f.lsp.connCtx, checkTimeout)
Expand Down
218 changes: 20 additions & 198 deletions private/buf/buflsp/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,230 +17,52 @@ package buflsp
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"slices"
"strings"

"buf.build/go/standard/xlog/xslog"
"buf.build/go/standard/xslices"
"github.com/bufbuild/buf/private/bufpkg/bufimage"
"github.com/bufbuild/protocompile"
"github.com/bufbuild/protocompile/experimental/report"
"github.com/bufbuild/protocompile/experimental/source"
"github.com/bufbuild/protocompile/linker"
"github.com/bufbuild/protocompile/parser"
"github.com/bufbuild/protocompile/protoutil"
"github.com/bufbuild/protocompile/reporter"
"github.com/google/uuid"
"go.lsp.dev/protocol"
"google.golang.org/protobuf/reflect/protoreflect"
)

// fileOpener is a type that opens files as they are named in the import
// statements of .proto files.
// buildImage builds a Buf Image for the given path using the new experimental compiler.
// This does not use the controller to build the image, because we need delicate control
// over the input files: namely, for the case when we depend on a file that has been
// opened and modified in the editor.
//
// This is the context given to [buildImage] to control what text to look up for
// specific files, so that we can e.g. use file contents that are still unsaved
// in the editor, or use files from a different commit for building an --against
// image.
type fileOpener map[string]string

func (p fileOpener) Open(path string) (io.ReadCloser, error) {
text, ok := p[path]
if !ok {
return nil, fmt.Errorf("%s: %w", path, fs.ErrNotExist)
}
return io.NopCloser(strings.NewReader(text)), nil
}

// buildImage builds a Buf Image for the given path. This does not use the controller to build
// the image, because we need delicate control over the input files: namely, for the case
// when we depend on a file that has been opened and modified in the editor.
// The opener should contain all files in the current workspace, including any unsaved
// modifications. Files not present in the opener are resolved via [source.WKTs].
func buildImage(
ctx context.Context,
path string,
logger *slog.Logger,
opener fileOpener,
opener source.Opener,
) (bufimage.Image, []protocol.Diagnostic) {
var errorsWithPos []reporter.ErrorWithPos
var warningErrorsWithPos []reporter.ErrorWithPos
var symbols linker.Symbols
compiler := protocompile.Compiler{
SourceInfoMode: protocompile.SourceInfoExtraOptionLocations,
Resolver: &protocompile.SourceResolver{Accessor: opener.Open},
Symbols: &symbols,
Reporter: reporter.NewReporter(
func(errorWithPos reporter.ErrorWithPos) error {
errorsWithPos = append(errorsWithPos, errorWithPos)
return nil
},
func(warningErrorWithPos reporter.ErrorWithPos) {
warningErrorsWithPos = append(warningErrorsWithPos, warningErrorWithPos)
},
),
}
image, rpt, err := bufimage.BuildImageFromOpener(ctx, logger, opener, []string{path})

var diagnostics []protocol.Diagnostic
compiled, err := compiler.Compile(ctx, path)
if err != nil {
if !errors.Is(err, context.Canceled) {
logger.WarnContext(ctx, "error building image", slog.String("path", path), xslog.ErrorAttr(err))
}
var errorWithPos reporter.ErrorWithPos
if errors.As(err, &errorWithPos) {
diagnostics = []protocol.Diagnostic{newDiagnostic(errorWithPos, false, opener, logger)}
}
if len(errorsWithPos) > 0 {
diagnostics = slices.Concat(diagnostics, xslices.Map(errorsWithPos, func(errorWithPos reporter.ErrorWithPos) protocol.Diagnostic {
return newDiagnostic(errorWithPos, false, opener, logger)
}))
}
}
if len(compiled) == 0 || compiled[0] == nil {
return nil, diagnostics // Image failed to build.
}
compiledFile := compiled[0]

syntaxMissing := make(map[string]bool)
pathToUnusedImports := make(map[string]map[string]bool)
for _, warningErrorWithPos := range warningErrorsWithPos {
if warningErrorWithPos.Unwrap() == parser.ErrNoSyntax {
syntaxMissing[warningErrorWithPos.GetPosition().Filename] = true
} else if unusedImport, ok := warningErrorWithPos.Unwrap().(linker.ErrorUnusedImport); ok {
path := warningErrorWithPos.GetPosition().Filename
unused, ok := pathToUnusedImports[path]
if !ok {
unused = map[string]bool{}
pathToUnusedImports[path] = unused
}
unused[unusedImport.UnusedImport()] = true
}
}

var imageFiles []bufimage.ImageFile
seen := map[string]bool{}

queue := []protoreflect.FileDescriptor{compiledFile}
for len(queue) > 0 {
descriptor := queue[len(queue)-1]
queue = queue[:len(queue)-1]

if seen[descriptor.Path()] {
var hasErrors bool
for _, diagnostic := range rpt.Diagnostics {
if diagnostic.Primary().IsZero() || diagnostic.Level() > report.Error {
continue
}
seen[descriptor.Path()] = true

unused, ok := pathToUnusedImports[descriptor.Path()]
var unusedIndices []int32
if ok {
unusedIndices = make([]int32, 0, len(unused))
}

imports := descriptor.Imports()
for i := range imports.Len() {
dep := imports.Get(i).FileDescriptor
if dep == nil {
logger.Warn(fmt.Sprintf("found nil FileDescriptor for import %s", imports.Get(i).Path()))
continue
}

queue = append(queue, dep)

if unused != nil {
if _, ok := unused[dep.Path()]; ok {
unusedIndices = append(unusedIndices, int32(i))
}
}
}

descriptorProto := protoutil.ProtoFromFileDescriptor(descriptor)
if descriptorProto == nil {
err = fmt.Errorf("protoutil.ProtoFromFileDescriptor() returned nil for %q", descriptor.Path())
break
}

var imageFile bufimage.ImageFile
imageFile, err = bufimage.NewImageFile(
descriptorProto,
nil,
uuid.UUID{},
"",
descriptor.Path(),
descriptor.Path() != path,
syntaxMissing[descriptor.Path()],
unusedIndices,
)
if err != nil {
break
}

imageFiles = append(imageFiles, imageFile)
logger.Debug(fmt.Sprintf("added image file for %s", descriptor.Path()))
hasErrors = true
diagnostics = append(diagnostics, reportDiagnosticToProtocolDiagnostic(diagnostic))
}

if err != nil {
logger.Warn("could not build image", slog.String("path", path), xslog.ErrorAttr(err))
if !errors.Is(err, context.Canceled) {
logger.WarnContext(ctx, "error building image", slog.String("path", path), xslog.ErrorAttr(err))
}
return nil, diagnostics
}

image, err := bufimage.NewImage(imageFiles)
if err != nil {
logger.Warn("could not build image", slog.String("path", path), xslog.ErrorAttr(err))
if hasErrors {
// Don't return an image when there are compile errors: the image may be
// incomplete, and lint checks on a broken image produce misleading results.
return nil, diagnostics
}

return image, diagnostics
}

// newDiagnostic converts a protocompile error into a diagnostic.
//
// Unfortunately, protocompile's errors are currently too meagre to provide full code
// spans; that will require a fix in the compiler.
func newDiagnostic(err reporter.ErrorWithPos, isWarning bool, opener fileOpener, logger *slog.Logger) protocol.Diagnostic {
startPos := err.Start()
endPos := err.End()
filename := startPos.Filename

// Convert positions to UTF-16 encoding for LSP.
// Fallback to byte-based column (will be wrong for non-ASCII).
startUtf16Col := startPos.Col - 1
endUtf16Col := endPos.Col - 1

// TODO: this is a temporary workaround for old diagnostic errors.
// When using the new compiler these conversions will be already handled.
if text, ok := opener[filename]; ok {
file := source.NewFile(filename, text)
startLoc := file.Location(startPos.Offset, positionalEncoding)
endLoc := file.Location(endPos.Offset, positionalEncoding)
startUtf16Col = startLoc.Column - 1
endUtf16Col = endLoc.Column - 1
} else {
logger.Warn(
"failed to open file for diagnostic position encoding",
slog.String("filename", filename),
)
}

start := protocol.Position{
Line: uint32(startPos.Line - 1),
Character: uint32(startUtf16Col),
}
end := protocol.Position{
Line: uint32(endPos.Line - 1),
Character: uint32(endUtf16Col),
}

severity := protocol.DiagnosticSeverityError
if isWarning {
severity = protocol.DiagnosticSeverityWarning
}

return protocol.Diagnostic{
Range: protocol.Range{Start: start, End: end},
Severity: severity,
Message: err.Unwrap().Error(),
Source: serverName,
}
}
Loading
Loading