diff --git a/CHANGELOG.md b/CHANGELOG.md index ffbd64f1d..606b96a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Reduced the log verbosity of the worker by changing various log messages from info to debug. [#1179](https://github.com/sourcebot-dev/sourcebot/pull/1179) +- [EE] Switched symbol hover detection to use Lezer highlight tags, broadening identifier coverage. [#1194](https://github.com/sourcebot-dev/sourcebot/pull/1194) ## [4.17.1] - 2026-05-04 diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.test.ts b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.test.ts new file mode 100644 index 000000000..e877b339f --- /dev/null +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, test } from 'vitest'; +import { EditorState, Extension } from '@codemirror/state'; +import { javascript } from '@codemirror/lang-javascript'; +import { python } from '@codemirror/lang-python'; +import { go } from '@codemirror/lang-go'; +import { rust } from '@codemirror/lang-rust'; +import { java } from '@codemirror/lang-java'; +import { cpp } from '@codemirror/lang-cpp'; +import { php } from '@codemirror/lang-php'; +import { symbolHoverTargetsExtension } from './symbolHoverTargetsExtension'; + +const collectDecoratedSpans = (doc: string, language: Extension): { texts: string[]; ranges: Array<{ from: number; to: number; text: string }> } => { + const state = EditorState.create({ + doc, + extensions: [language, symbolHoverTargetsExtension], + }); + const decorations = state.field(symbolHoverTargetsExtension); + const ranges: Array<{ from: number; to: number; text: string }> = []; + const iter = decorations.iter(); + while (iter.value) { + ranges.push({ from: iter.from, to: iter.to, text: doc.slice(iter.from, iter.to) }); + iter.next(); + } + return { texts: ranges.map(r => r.text), ranges }; +}; + +const expectAllDetected = (texts: string[], expected: string[]) => { + const found = new Set(texts); + const missing = expected.filter(name => !found.has(name)); + expect(missing, `Expected identifiers were not detected: ${missing.join(', ')}`).toEqual([]); +}; + +const expectNoneDetected = (texts: string[], unexpected: string[]) => { + const found = new Set(texts); + const present = unexpected.filter(name => found.has(name)); + expect(present, `Unexpected identifiers were detected: ${present.join(', ')}`).toEqual([]); +}; + +describe('symbolHoverTargetsExtension', () => { + test('TypeScript: detects functions, classes, props, types, and JSX', () => { + const doc = [ + 'import { useState } from "react";', + '', + 'interface UserProps {', + ' id: number;', + ' name: string;', + '}', + '', + 'class UserCard {', + ' private props: UserProps;', + ' getDisplayName(): string { return this.props.name; }', + '}', + '', + 'function renderCard(user: UserProps) {', + ' const [count, setCount] = useState(0);', + ' return {user.name};', + '}', + ].join('\n'); + + const { texts } = collectDecoratedSpans(doc, javascript({ jsx: true, typescript: true })); + + expectAllDetected(texts, [ + 'useState', + 'UserProps', + 'id', + 'name', + 'UserCard', + 'props', + 'getDisplayName', + 'renderCard', + 'user', + 'count', + 'setCount', + ]); + expectNoneDetected(texts, ['import', 'from', 'interface', 'class', 'function', 'const', 'return']); + }); + + test('Python: detects functions, classes, methods, and parameters', () => { + const doc = [ + 'from typing import List', + '', + 'class Greeter:', + ' def __init__(self, name: str):', + ' self.name = name', + '', + ' def greet(self, others: List[str]) -> str:', + ' return f"Hello {self.name} and {others}"', + '', + 'def main():', + ' greeter = Greeter("World")', + ' print(greeter.greet(["a", "b"]))', + ].join('\n'); + + const { texts } = collectDecoratedSpans(doc, python()); + + expectAllDetected(texts, [ + 'Greeter', + '__init__', + 'self', + 'name', + 'greet', + 'others', + 'main', + 'greeter', + 'print', + ]); + expectNoneDetected(texts, ['from', 'import', 'class', 'def', 'return']); + }); + + test('Go: detects functions, types, fields, and method receivers', () => { + const doc = [ + 'package main', + '', + 'import "fmt"', + '', + 'type User struct {', + ' ID int', + ' Name string', + '}', + '', + 'func (u *User) DisplayName() string {', + ' return u.Name', + '}', + '', + 'func main() {', + ' user := User{ID: 1, Name: "Alice"}', + ' fmt.Println(user.DisplayName())', + '}', + ].join('\n'); + + const { texts } = collectDecoratedSpans(doc, go()); + + expectAllDetected(texts, [ + 'User', + 'ID', + 'Name', + 'DisplayName', + 'main', + 'user', + 'fmt', + 'Println', + ]); + expectNoneDetected(texts, ['package', 'import', 'type', 'struct', 'func', 'return']); + }); + + test('Rust: detects structs, traits, functions, and bound identifiers', () => { + const doc = [ + 'use std::fmt::Display;', + '', + 'struct Point {', + ' x: i32,', + ' y: i32,', + '}', + '', + 'trait Drawable {', + ' fn draw(&self);', + '}', + '', + 'impl Drawable for Point {', + ' fn draw(&self) {', + ' let location = (self.x, self.y);', + ' println!("{:?}", location);', + ' }', + '}', + ].join('\n'); + + const { texts } = collectDecoratedSpans(doc, rust()); + + expectAllDetected(texts, [ + 'Point', + 'x', + 'y', + 'Drawable', + 'draw', + 'location', + ]); + expectNoneDetected(texts, ['use', 'struct', 'trait', 'impl', 'fn', 'let']); + }); + + test('Java: detects classes, methods, fields, and parameters', () => { + const doc = [ + 'package com.example;', + '', + 'public class Calculator {', + ' private int total;', + '', + ' public Calculator(int initial) {', + ' this.total = initial;', + ' }', + '', + ' public int add(int value) {', + ' total = total + value;', + ' return total;', + ' }', + '}', + ].join('\n'); + + const { texts } = collectDecoratedSpans(doc, java()); + + expectAllDetected(texts, [ + 'Calculator', + 'total', + 'initial', + 'add', + 'value', + ]); + expectNoneDetected(texts, ['package', 'public', 'class', 'private', 'return']); + }); + + test('C++: detects functions, classes, namespaces, and fields', () => { + const doc = [ + '#include ', + '', + 'namespace geom {', + ' class Shape {', + ' public:', + ' Shape(int sides);', + ' int getSides() const;', + ' private:', + ' int sides_;', + ' };', + '}', + '', + 'int main() {', + ' geom::Shape triangle(3);', + ' return triangle.getSides();', + '}', + ].join('\n'); + + const { texts } = collectDecoratedSpans(doc, cpp()); + + expectAllDetected(texts, [ + 'geom', + 'Shape', + 'getSides', + 'main', + 'triangle', + ]); + expectNoneDetected(texts, ['namespace', 'class', 'public', 'private', 'return', 'const']); + }); + + test('PHP: detects classes, methods, properties, and variables', () => { + const doc = [ + 'name = $name;', + ' }', + '', + ' public function greet(): string {', + ' return "Hello " . $this->name;', + ' }', + '}', + ].join('\n'); + + const { texts } = collectDecoratedSpans(doc, php()); + + expectAllDetected(texts, [ + 'Greeter', + 'name', + '__construct', + 'greet', + ]); + expectNoneDetected(texts, ['namespace', 'class', 'private', 'public', 'function', 'return']); + }); + + test('skips zero-width and non-identifier nodes', () => { + const doc = 'const x = 42;'; + const { ranges } = collectDecoratedSpans(doc, javascript({ typescript: true })); + + for (const range of ranges) { + expect(range.to).toBeGreaterThan(range.from); + } + + expectAllDetected(ranges.map(r => r.text), ['x']); + expectNoneDetected(ranges.map(r => r.text), ['42', 'const', '=']); + }); + + test('returns empty decoration set for plain text without a language', () => { + const state = EditorState.create({ + doc: 'just some plain prose with no grammar attached', + extensions: [symbolHoverTargetsExtension], + }); + const decorations = state.field(symbolHoverTargetsExtension); + expect(decorations.size).toBe(0); + }); +}); diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts index 56761b2f0..4052db88f 100644 --- a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts +++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension.ts @@ -1,6 +1,7 @@ import { StateField, Range } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; import { ensureSyntaxTree } from "@codemirror/language"; +import { getStyleTags, tags as t } from "@lezer/highlight"; import { measureSync } from "@/lib/utils"; export const SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE = "data-symbol-hover-target"; @@ -10,48 +11,6 @@ const decoration = Decoration.mark({ attributes: { [SYMBOL_HOVER_TARGET_DATA_ATTRIBUTE]: "true" } }); -const NODE_TYPES = [ - // Typescript + Python - "VariableName", - "VariableDefinition", - "TypeDefinition", - "TypeName", - "PropertyName", - "PropertyDefinition", - "JSXIdentifier", - "Identifier", - // C# - "VarName", - "TypeIdentifier", - "PropertyName", - "MethodName", - "Ident", - "ParamName", - "AttrsNamedArg", - // C/C++ - "Identifier", - "NamespaceIdentifier", - "FieldIdentifier", - // Objective-C - "variableName", - "variableName.definition", - // Java - "Definition", - // Rust - "BoundIdentifier", - // Go - "DefName", - "FieldName", - // PHP - "ClassMemberName", - "Name", - // Tcl - "ProcName", - "ProcInvocation", - "PackageName", - "Variable" -] - export const symbolHoverTargetsExtension = StateField.define({ create(state) { // @note: we need to use `ensureSyntaxTree` here (as opposed to `syntaxTree`) @@ -60,16 +19,20 @@ export const symbolHoverTargetsExtension = StateField.define({ const { data: tree } = measureSync(() => ensureSyntaxTree(state, state.doc.length, Infinity), "ensureSyntaxTree"); const decorations: Range[] = []; - // @note: useful for debugging - // const getTextAt = (from: number, to: number) => { - // const doc = state.doc; - // return doc.sliceString(from, to); - // } - tree?.iterate({ enter: (node) => { - // console.log(node.type.name, getTextAt(node.from, node.to)); - if (NODE_TYPES.includes(node.type.name) && node.from < node.to) { + if (node.from >= node.to) { + return; + } + const styleTags = getStyleTags(node); + if (!styleTags) { + return; + } + // `Tag.set` is a tag's parent chain. All identifier-shaped highlight tags + // (variableName, typeName, propertyName, etc.) — including modifier-wrapped + // forms like `definition(variableName)` — descend from `tags.name`. + const isIdentifier = styleTags.tags.some(tag => tag.set.includes(t.name)); + if (isIdentifier) { decorations.push(decoration.range(node.from, node.to)); } }, diff --git a/packages/web/src/features/codeNav/api.ts b/packages/web/src/features/codeNav/api.ts index d45004795..60a36ffd7 100644 --- a/packages/web/src/features/codeNav/api.ts +++ b/packages/web/src/features/codeNav/api.ts @@ -153,6 +153,24 @@ const parseRelatedSymbolsSearchResponse = (searchResult: SearchResponse): FindRe // Expands the language filter to include all variants of the language. const getExpandedLanguageFilter = (language: string): QueryIR => { switch (language) { + case "C": + case "C++": + return { + or: { + children: [ + { + language: { + language: "C++" + } + }, + { + language: { + language: "C" + } + } + ] + } + } case "TypeScript": case "JavaScript": case "JSX":