Skip to content

Map CEL offsets through the escapes protocompile records - #4739

Merged
anuraaga merged 3 commits into
bufbuild:mainfrom
stefanvanburen:svanburen/cel-escapes-from-tokens
Oct 23, 2026
Merged

anuraaga merged 3 commits into
bufbuild:mainfrom
stefanvanburen:svanburen/cel-escapes-from-tokens

Conversation

@stefanvanburen

@stefanvanburen stefanvanburen commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

The LSP maps offsets between a CEL expression and the proto string literals it is written as, in both directions: to place hover ranges and semantic tokens, and to find the CEL token under the cursor. It did that by lexing the literals again by hand, which got escape sequences wrong in the three ways the commit message lists. protocompile already records each escape's span and decoded value on the string token, so this builds the mapping from that instead and deletes buf's copy of the escape lexer.

flowchart LR
    cel["cel-go offset range"] -->|"celOffsetRangeToByteRange (#4737)"| expr["CEL expression byte offset"]
    tok["StringToken and its Escapes"] -->|newCELSourceMap| map["celSourceMap"]
    expr -->|"celSourceMap.span"| span[".proto file span"]
    pos[".proto file offset"] -->|"celSourceMap.offset"| expr
Loading

The bugs are cosmetic and escapes in CEL expressions are uncommon (2 of the 326 rules in protovalidate's validate.proto use one), so the main gain is the deleted lexer rather than the fixes.

The new cases go in TestCELHover's table, which gains optional start and end fields so that they can check the hover range as well as its content.

This supersedes #4738 and carries over its test cases. It also counts a \u escape of a value that is not a valid rune at the three bytes of the U+FFFD the lexer decodes it to; without that, one such escape left the whole expression without hovers.

@stefanvanburen
stefanvanburen force-pushed the svanburen/cel-escapes-from-tokens branch 3 times, most recently from b5efccf to 20b64ac Compare October 15, 2026 19:41
@stefanvanburen
stefanvanburen marked this pull request as ready for review October 15, 2026 19:41
The LSP mapped offsets between a CEL expression and the proto string
literals it was written as by lexing the literals again, by hand, in
`createCELSpan` and `fileByteOffsetToCELOffset`. Each had its own copy
of the walk, and both got escapes wrong:

- a token's end was taken as its start plus its length in the
  expression, so a CEL string literal written with escapes, such as
  `\"a\"` or `'\\n'`, was highlighted and hovered short of its end;
- each escape counted as one byte of the expression, while `\u` and
  `\U` decode to up to four, so every token after one was misplaced:
  hovering on the second `this` in
  `this != '\u00e9' && this != ''` showed the `!=` before it;
- an offset inside an escape mapped to nothing, so hovering on the
  `"` of `\"` found no token.

`celSourceMap` is built once per expression from the string token
protocompile lexed, using the escapes it records with their spans and
decoded values, and maps in both directions. It replaces both walks and
the escape lexer they shared.

`token.StringToken.Escapes` also yields each rune that follows a
string's first escape, with an empty span, which the map skips.

An escape of a value that is not a valid rune, such as `\uD800`, is
reported by the lexer and decoded as U+FFFD, three bytes long, and the
map counts it that way.

The semantic tokens case and the hover cases after a `\u` or `\U`
escape are from bufbuild#4738.
@stefanvanburen
stefanvanburen force-pushed the svanburen/cel-escapes-from-tokens branch from 20b64ac to 0d1175f Compare October 15, 2026 19:45

@anuraaga anuraaga left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Few small points

Comment thread private/buf/buflsp/cel.go Resolved
Comment thread private/buf/buflsp/cel.go Resolved
Comment thread private/buf/buflsp/cel.go Outdated
end := i + 2
for end < len(s) && end < i+6 && isProtoHexDigit(s[end]) {
end++
// Escapes also yields each rune that follows a string's first escape, with an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH can't understand what this comment is trying to say to even give a suggestion to make it simpler

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's confusing because protocompile's Escapes() has an entry for every rune that follows it, escaped or not. Could send protocompile change that makes Escapes record only escape sequences, which seems more correct to me?

Comment thread private/buf/buflsp/cel.go Resolved
Comment thread private/buf/buflsp/cel.go Resolved
Comment thread private/buf/buflsp/cel.go Resolved
`celSourceMap` kept parallel slices of start and end offsets beside the
file they were in. `celSourceSpans` is a `[]source.Span` instead, one
span per byte of the expression, and is built by walking the escape
sequences in order, adding the plain bytes between them, rather than by
walking the contents and checking each byte against the next escape.

@anuraaga anuraaga left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay, thought I had approved this already. Thanks!

@anuraaga
anuraaga merged commit 2bca246 into bufbuild:main Oct 23, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants