diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2281d61..b4520db 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,7 +4,12 @@ on: push: tags: - "v*" - workflow_dispatch: {} + workflow_dispatch: + inputs: + version: + description: "Release version without leading v. Must match package.json." + required: true + type: string jobs: release: @@ -22,18 +27,26 @@ jobs: - name: Install run: npm ci + - name: Verify release metadata + env: + RELEASE_VERSION: ${{ inputs.version }} + run: npm run release:verify:metadata + - name: Test run: npm test - name: Package VSIX - run: | - mkdir -p dist - npx @vscode/vsce package --out dist/JumpProto.vsix + run: npm run vsix + + - name: Verify VSIX + env: + RELEASE_VERSION: ${{ inputs.version }} + run: npm run release:verify - name: Publish to VS Code Marketplace env: VSCE_PAT: ${{ secrets.VSCE_PAT }} - run: npx @vscode/vsce publish -p "$VSCE_PAT" + run: npx @vscode/vsce publish --packagePath dist/JumpProto.vsix -p "$VSCE_PAT" - name: Publish to Open VSX env: @@ -43,4 +56,5 @@ jobs: - name: Create GitHub Release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ github.ref_type == 'tag' && github.ref_name || format('v{0}', inputs.version) }} files: dist/JumpProto.vsix diff --git a/.jumpjump/bookmarks.json b/.jumpjump/bookmarks.json new file mode 100644 index 0000000..0df4faf --- /dev/null +++ b/.jumpjump/bookmarks.json @@ -0,0 +1,36 @@ +{ + "version": 2, + "groups": [ + { + "id": "ungrouped", + "name": "未分组", + "order": 0, + "collapsed": false, + "sortBy": "createdAt", + "sortDirection": "desc", + "system": true + }, + { + "id": "group-测试", + "name": "测试", + "order": 1, + "collapsed": false, + "sortBy": "manual", + "sortDirection": "asc" + } + ], + "items": [ + { + "id": "50354de4-e8a6-4d43-9ec0-0a2d64aa4a24", + "type": "line", + "label": "verify-release.mjs:1", + "path": "scripts/verify-release.mjs", + "line": 1, + "groupId": "group-测试", + "lastOpenedAt": "2026-04-29T11:59:10.192Z", + "createdAt": "2026-04-29T11:58:41.438Z", + "updatedAt": "2026-04-29T11:59:02.594Z", + "manualOrder": 0 + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29..31ad69f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog + +## 1.0.8 - 2026-04-29 + +- Split extension lifecycle, configuration, navigation, usage search, compile, diagnostics, and command registration into focused modules. +- Added integration fixtures covering Go/proto navigation, workspace-external proto roots, import aliases, same-package usages, nested messages, fields, enums, services, and RPCs. +- Added `JumpProto: Diagnose Current Symbol` for troubleshooting navigation failures from the output channel. +- Added release checks for matching release versions, non-empty changelog entries, passing tests, and verified VSIX contents. +- Added sidebar self-check actions for testing navigation, opening output, and previewing the rendered Make Proto command. +- Documented support boundaries and troubleshooting notes in English and Chinese. +- Improved usage-search performance with cancellable scans, cached Go/proto metadata, workspace file-change invalidation, and configurable `protoJump.exclude` patterns. diff --git a/README.md b/README.md index 977cfc9..2dae182 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Use it to jump from `.pb.go` symbols to `message`, `enum`, `service`, `rpc`, and - `.proto -> Go` usage search for definitions, field types, and field names. - Sidebar configuration for Proto roots, workspace fallback search, UI language, and Make Proto rules. - `Compile Current Proto` command with placeholder-based shell templates. -- Dry-run command testing before running the compile rule. +- Dry-run command testing, navigation testing, output access, and rendered command preview from the sidebar. ## Highlights @@ -41,6 +41,9 @@ Use it to jump from `.pb.go` symbols to `message`, `enum`, `service`, `rpc`, and - Configure multiple `protoRoots` for repositories with separated Go and Proto source trees. - Fall back to workspace search when `protoRoots` does not resolve a source file. - Edit, test, and run a project-specific Proto compile command from the JumpProto sidebar. +- Preview the rendered Make Proto command for the active `.proto` file before running it. +- Test the current cursor's navigation result and open the `JumpProto` output channel from the sidebar. +- Cache workspace scans and support cancellable usage searches for large repositories. - Switch JumpProto UI text between English and Chinese. ## Quick Start @@ -57,7 +60,7 @@ Use it to jump from `.pb.go` symbols to `message`, `enum`, `service`, `rpc`, and - From Proto, run `JumpProto: Go to Go Usage` on a `message`, `enum`, `service`, `rpc`, field type, or field name. - From a `.proto` file, run `JumpProto: Compile Current Proto` after configuring a Make Proto rule. - Use the status bar entry while editing Go files as a shortcut to Proto definition navigation. -- Use the JumpProto Activity Bar sidebar to manage roots, language, workspace search, and compile rules. +- Use the JumpProto Activity Bar sidebar to manage roots, language, workspace search, compile rules, navigation tests, and output logs. When `gopls` is enabled, VS Code may show both generated Go and Proto definition candidates. Choose the `.proto` candidate when you want the source definition. @@ -68,6 +71,9 @@ When `gopls` is enabled, VS Code may show both generated Go and Proto definition | `JumpProto: Go to Proto Definition` | Opens the source `.proto` definition for the generated Go symbol under the cursor. | | `JumpProto: Go to Go Usage` | Finds Go usage for the Proto symbol or field under the cursor. | | `JumpProto: Compile Current Proto` | Runs the configured shell command template for the active `.proto` file. | +| `JumpProto: Test Navigation` | Resolves the current cursor target and writes the result to output without jumping. | +| `JumpProto: Open Output` | Opens the `JumpProto` output channel. | +| `JumpProto: Diagnose Current Symbol` | Writes detailed navigation diagnostics for the current cursor to output. | | `JumpProto: Add Proto Root` | Adds a source `.proto` root directory. | | `JumpProto: Remove Proto Root` | Removes a configured source `.proto` root directory. | | `JumpProto: Toggle Search In Workspace` | Enables or disables workspace fallback search. | @@ -121,6 +127,27 @@ Supported placeholders: | `{relativeProtoNoExt}` | Path relative to `protoSrcRoot`, without `.proto`. | | `{protoPackage}` | Package segment inferred from `go_package` or `package`. | +### `protoJump.exclude` + +- Type: `string[]` +- Default: `["**/node_modules/**", "**/vendor/**", "**/out/**", "**/dist/**", "**/.git/**"]` +- Description: glob patterns excluded from JumpProto workspace searches. + +Use this to skip generated, vendor, build, or large directories: + +```json +{ + "protoJump.exclude": [ + "**/node_modules/**", + "**/vendor/**", + "**/out/**", + "**/dist/**", + "**/.git/**", + "**/third_party/big_generated/**" + ] +} +``` + ### `protoJump.uiLanguage` - Type: `"zh" | "en"` @@ -150,6 +177,17 @@ esac Use `Test Command` in the sidebar before compiling. It performs a shell syntax dry run and writes the rendered command to the `JumpProto` output channel. +When a `.proto` file is active, the sidebar shows the command rendered with the current file context. If the preview is unavailable, confirm the file is under `protoJump.protoRoots` or a detectable `proto_src` directory. + +## Support Boundaries / Troubleshooting + +- Go -> Proto depends on VS Code and `gopls` resolving the cursor symbol to a generated `.pb.go` definition first. JumpProto then reads the generated file header and follows `// source: path/to/file.proto`. +- Proto -> Go usage is static workspace search, not a full Go type-system reference query. It covers common qualified imports, import aliases, same-package bare names, field access, getters, and composite literals. +- Usage search caches Go file lists, file text, `.pb.go` headers, and inferred Go package data. The cache is cleared when Go/Proto files or `protoJump` settings change. +- Import aliases, default imports, same-package bare names, nested messages, fields, enums, services, and RPCs are supported for common generated-Go layouts, but dynamic reflection or custom wrappers may still be missed. +- `Compile Current Proto` determines `{protoSrcRoot}` from configured `protoJump.protoRoots` first, then from detectable `proto_src` ancestors with a `Makefile`. +- Use `JumpProto: Test Navigation` to check the current cursor without moving editors. Use `JumpProto: Diagnose Current Symbol` when navigation fails; it logs editor context, definition-provider results, source header resolution, proto root candidates, Go package inference, and usage-search strategy to the `JumpProto` output channel. + ## Requirements - Generated Go files should be created by `protoc-gen-go`. @@ -166,8 +204,10 @@ Use `Test Command` in the sidebar before compiling. It performs a shell syntax d - Go-to-Proto navigation depends on VS Code first resolving the Go symbol to a generated `.pb.go` definition. - Proto-to-Go usage search is heuristic and capped to keep workspace scans responsive. +- Usage searches are capped at 200 references and can be cancelled from the progress notification. - Field usage search can miss complex aliasing, reflection, generated helper wrappers, or heavily dynamic code. - Proto compile commands run locally through `/bin/zsh`. +- Use `JumpProto: Open Output` to inspect dry-run commands, diagnostics, and navigation test results. ## Privacy @@ -199,7 +239,7 @@ JumpProto 会把生成后的 Go 代码重新连回源 `.proto` 定义,也能 - 支持从 `.proto` 反查 Go 使用处,覆盖定义、字段类型和字段名。 - 侧边栏集中管理 Proto 根目录、工作区兜底搜索、界面语言和 Make Proto 规则。 - `Compile Current Proto` 支持基于占位符的 shell 命令模板。 -- 编译前可以先执行 dry-run 测试命令。 +- 侧边栏支持 dry-run 测试命令、测试跳转、打开输出和查看展开后的命令预览。 ## 功能亮点 @@ -211,6 +251,9 @@ JumpProto 会把生成后的 Go 代码重新连回源 `.proto` 定义,也能 - 支持配置多个 `protoRoots`,适配 Go 代码和 Proto 源码分离的仓库。 - 当 `protoRoots` 未命中时,可继续在当前工作区兜底搜索。 - 在 JumpProto 侧边栏里编辑、测试并运行项目自己的 Proto 编译命令。 +- 在执行前预览当前 `.proto` 文件展开后的 Make Proto 命令。 +- 从侧边栏测试当前光标的跳转结果,并快速打开 `JumpProto` 输出面板。 +- 缓存工作区扫描,并支持在大仓库里取消 usage 搜索。 - JumpProto 侧边栏和提示消息支持英文与中文切换。 ## 快速开始 @@ -227,7 +270,7 @@ JumpProto 会把生成后的 Go 代码重新连回源 `.proto` 定义,也能 - 在 Proto 文件中,对 `message`、`enum`、`service`、`rpc`、字段类型或字段名执行 `JumpProto: Go to Go Usage`。 - 在 `.proto` 文件中,配置 Make Proto 规则后执行 `JumpProto: Compile Current Proto`。 - 编辑 Go 文件时,可以使用状态栏里的 JumpProto 入口快速跳转到 Proto 定义。 -- 使用 Activity Bar 里的 JumpProto 侧边栏管理根目录、语言、工作区搜索和编译规则。 +- 使用 Activity Bar 里的 JumpProto 侧边栏管理根目录、语言、工作区搜索、编译规则、跳转测试和输出日志。 启用 `gopls` 时,VS Code 可能同时给出生成 Go 和 Proto 定义候选。需要源定义时,选择 `.proto` 候选即可。 @@ -238,6 +281,9 @@ JumpProto 会把生成后的 Go 代码重新连回源 `.proto` 定义,也能 | `JumpProto: Go to Proto Definition` | 打开光标下生成 Go 符号对应的源 `.proto` 定义。 | | `JumpProto: Go to Go Usage` | 查找光标下 Proto 符号或字段的 Go 使用处。 | | `JumpProto: Compile Current Proto` | 按当前 `.proto` 文件上下文运行已配置的 shell 命令模板。 | +| `JumpProto: Test Navigation` | 解析当前光标的目标并写入输出面板,不执行跳转。 | +| `JumpProto: Open Output` | 打开 `JumpProto` 输出面板。 | +| `JumpProto: Diagnose Current Symbol` | 将当前光标的详细诊断信息写入输出面板。 | | `JumpProto: Add Proto Root` | 添加源 `.proto` 根目录。 | | `JumpProto: Remove Proto Root` | 移除已配置的源 `.proto` 根目录。 | | `JumpProto: Toggle Search In Workspace` | 开启或关闭工作区兜底搜索。 | @@ -291,6 +337,27 @@ cd {protoSrcRoot} && make special_proto packagename={protoPackage} filename={pro | `{relativeProtoNoExt}` | 相对 `protoSrcRoot` 的路径,不含 `.proto`。 | | `{protoPackage}` | 根据 `go_package` 或 `package` 推导出的包名片段。 | +### `protoJump.exclude` + +- 类型:`string[]` +- 默认:`["**/node_modules/**", "**/vendor/**", "**/out/**", "**/dist/**", "**/.git/**"]` +- 说明:JumpProto 工作区搜索时排除的 glob 规则。 + +可以用它跳过生成目录、vendor、构建目录或体积很大的目录: + +```json +{ + "protoJump.exclude": [ + "**/node_modules/**", + "**/vendor/**", + "**/out/**", + "**/dist/**", + "**/.git/**", + "**/third_party/big_generated/**" + ] +} +``` + ### `protoJump.uiLanguage` - 类型:`"zh" | "en"` @@ -320,6 +387,17 @@ esac 编译前建议先在侧边栏点击 `Test Command`。它只做 shell 语法 dry-run,并把展开后的命令写入 `JumpProto` 输出面板。 +当当前活动文件是 `.proto` 时,侧边栏会展示按当前文件上下文展开后的命令。如果无法预览,请确认该文件位于 `protoJump.protoRoots`,或位于可识别的 `proto_src` 目录下。 + +## 支持边界 / Troubleshooting + +- Go -> Proto 依赖 VS Code 和 `gopls` 先把光标符号解析到生成的 `.pb.go` 定义。JumpProto 再读取生成文件头部,并根据 `// source: path/to/file.proto` 回源。 +- Proto -> Go usage 是静态工作区搜索,不等同于完整 Go 类型系统引用查询。它覆盖常见的带包名引用、import alias、同包裸名、字段访问、getter 和结构体字面量。 +- Usage 搜索会缓存 Go 文件列表、文件内容、`.pb.go` 头部和推断出的 Go package 信息;当 Go/Proto 文件或 `protoJump` 配置变化时会自动失效。 +- import alias、默认 import、同包裸名、nested message、字段、enum、service、rpc 会覆盖常见生成 Go 布局,但反射、动态代码或自定义包装仍可能漏掉。 +- `Compile Current Proto` 会优先从 `protoJump.protoRoots` 判断 `{protoSrcRoot}`,然后尝试识别带 `Makefile` 的 `proto_src` 祖先目录。 +- 使用 `JumpProto: Test Navigation` 可以只检查当前光标解析结果而不跳转。跳转失败时使用 `JumpProto: Diagnose Current Symbol`,它会把编辑器上下文、definition provider 结果、source header 解析、proto root 候选、Go package 推断和 usage 搜索策略写入 `JumpProto` 输出面板。 + ## 前置条件 - Go 代码应由 `protoc-gen-go` 生成。 @@ -336,8 +414,10 @@ esac - Go 到 Proto 跳转依赖 VS Code 先把 Go 符号解析到生成的 `.pb.go` 定义。 - Proto 到 Go 的使用处搜索是启发式扫描,并会限制结果数量以保持响应速度。 +- Usage 搜索最多返回 200 个引用,并可在进度通知里取消。 - 字段使用处搜索可能漏掉复杂别名、反射、生成辅助包装或高度动态的代码。 - Proto 编译命令会在本机通过 `/bin/zsh` 执行。 +- 可以用 `JumpProto: Open Output` 查看 dry-run 命令、诊断信息和跳转测试结果。 ## 隐私 diff --git a/package.json b/package.json index d99c32c..a8ea6d7 100644 --- a/package.json +++ b/package.json @@ -4,28 +4,29 @@ "description": "在 Go 与 .proto 之间双向跳转,支持字段与内部元素级精确定位。", "version": "1.0.8", "icon": "resources/marketplace-icon.png", + "files": [ + "out", + "resources", + "package.json", + "README.md", + "CHANGELOG.md", + "LICENSE", + "NOTICE", + "THIRD_PARTY_NOTICES.md" + ], "publisher": "SivanLiu", "license": "Apache-2.0", "repository": { "type": "git", "url": "https://github.com/SivanCola/JumpProto.git" }, + "homepage": "https://github.com/SivanCola/JumpProto#readme", "bugs": { "url": "https://github.com/SivanCola/JumpProto/issues" }, "engines": { "vscode": "^1.70.0" }, - "files": [ - "out/**", - "resources/proto-jump-icon.svg", - "resources/marketplace-icon.png", - "package.json", - "README.md", - "LICENSE", - "NOTICE", - "THIRD_PARTY_NOTICES.md" - ], "categories": [ "Programming Languages", "Other" @@ -58,6 +59,9 @@ "onCommand:protoJump.editMakeProtoRule", "onCommand:protoJump.openMakeProtoRuleHelp", "onCommand:protoJump.compileCurrentProto", + "onCommand:protoJump.diagnoseCurrentSymbol", + "onCommand:protoJump.testNavigation", + "onCommand:protoJump.openOutput", "onView:protoJump.view" ], "main": "./out/extension.js", @@ -106,6 +110,18 @@ { "command": "protoJump.compileCurrentProto", "title": "JumpProto: Compile Current Proto" + }, + { + "command": "protoJump.diagnoseCurrentSymbol", + "title": "JumpProto: Diagnose Current Symbol" + }, + { + "command": "protoJump.testNavigation", + "title": "JumpProto: Test Navigation" + }, + { + "command": "protoJump.openOutput", + "title": "JumpProto: Open Output" } ], "menus": { @@ -124,6 +140,11 @@ "command": "protoJump.compileCurrentProto", "when": "editorTextFocus && resourceExtname == .proto", "group": "navigation@52" + }, + { + "command": "protoJump.diagnoseCurrentSymbol", + "when": "editorTextFocus && (editorLangId == go || resourceExtname == .proto)", + "group": "navigation@53" } ], "view/item/context": [] @@ -176,16 +197,35 @@ "type": "string", "default": "", "description": "Shell command template used to compile the current .proto file. Supported placeholders: {workspaceFolder}, {protoSrcRoot}, {protoFile}, {protoFileNoExt}, {protoDir}, {relativeProto}, {relativeProtoNoExt}, {protoPackage}." + }, + "protoJump.exclude": { + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "**/node_modules/**", + "**/vendor/**", + "**/out/**", + "**/dist/**", + "**/.git/**" + ], + "description": "Glob patterns excluded from JumpProto workspace searches. Use this to skip generated, vendor, build, or large directories." } } } }, "scripts": { "compile": "tsc -p .", + "build": "npm run compile", + "lint": "tsc -p . --noEmit", "dev": "npm run watch", "dev:host": "code --extensionDevelopmentPath=.", "reinstall:vsix": "npm run vsix && code --install-extension dist/JumpProto.vsix --force", "vsix": "npm run compile && node -e \"require('fs').mkdirSync('dist',{recursive:true})\" && npx @vscode/vsce package --out dist/JumpProto.vsix", + "release:verify": "node scripts/verify-release.mjs", + "release:verify:metadata": "node scripts/verify-release.mjs --skip-vsix", + "release:check": "npm run release:verify:metadata && npm test && npm run vsix && npm run release:verify", "test": "npm run compile && node --test out/*.test.js", "watch": "tsc -watch -p .", "vscode:prepublish": "npm run compile" diff --git a/resources/proto-jump-icon.svg b/resources/proto-jump-icon.svg index 20e8e68..d9bd24f 100644 --- a/resources/proto-jump-icon.svg +++ b/resources/proto-jump-icon.svg @@ -1,5 +1,10 @@ - - - + + + + + + + + + - diff --git a/scripts/verify-release.mjs b/scripts/verify-release.mjs new file mode 100644 index 0000000..f72af65 --- /dev/null +++ b/scripts/verify-release.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node + +import { inflateRawSync } from "node:zlib"; +import { readFileSync, statSync } from "node:fs"; +import { resolve } from "node:path"; + +const args = new Set(process.argv.slice(2)); +const skipVsix = args.has("--skip-vsix"); +const vsixPath = resolve(process.cwd(), process.env.VSIX_PATH || "dist/JumpProto.vsix"); +const packageJson = JSON.parse(readFileSync("package.json", "utf8")); +const version = packageJson.version; +const expectedTag = `v${version}`; + +function fail(message) { + console.error(`Release verification failed: ${message}`); + process.exit(1); +} + +function pass(message) { + console.log(`ok - ${message}`); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function verifyReleaseRef() { + if (process.env.GITHUB_ACTIONS !== "true") { + console.warn("warn - not running in GitHub Actions; skipped release ref check"); + return; + } + + const refType = process.env.GITHUB_REF_TYPE || ""; + const refName = process.env.GITHUB_REF_NAME || ""; + const releaseVersion = process.env.RELEASE_VERSION || ""; + + if (refType === "tag") { + if (refName !== expectedTag) { + fail(`tag ${refName} does not match package.json version ${version}; expected ${expectedTag}`); + } + pass(`tag ${refName} matches package.json version`); + return; + } + + if (!releaseVersion) { + fail(`manual releases must provide RELEASE_VERSION=${version} or run from tag ${expectedTag}`); + } + + if (releaseVersion !== version) { + fail(`manual release version ${releaseVersion} does not match package.json version ${version}`); + } + + pass(`manual release version ${releaseVersion} matches package.json version`); +} + +function verifyChangelog() { + const changelog = readFileSync("CHANGELOG.md", "utf8"); + const headingPattern = new RegExp(`^#{1,3}\\s*(?:\\[)?v?${escapeRegExp(version)}(?:\\])?(?:\\s|$|[-:])`, "im"); + const match = headingPattern.exec(changelog); + + if (!match) { + fail(`CHANGELOG.md does not contain a heading for version ${version}`); + } + + const sectionStart = match.index + match[0].length; + const rest = changelog.slice(sectionStart); + const nextHeading = rest.search(/^#{1,3}\s+/m); + const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading); + const hasContent = section + .split(/\r?\n/) + .map((line) => line.trim()) + .some((line) => line && !line.startsWith("#")); + + if (!hasContent) { + fail(`CHANGELOG.md section for version ${version} is empty`); + } + + pass(`CHANGELOG.md contains release notes for ${version}`); +} + +function findEndOfCentralDirectory(buffer) { + const signature = 0x06054b50; + const minOffset = Math.max(0, buffer.length - 0xffff - 22); + + for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) { + if (buffer.readUInt32LE(offset) === signature) { + return offset; + } + } + + fail("VSIX is not a valid zip archive"); +} + +function readZipEntries(filePath) { + const buffer = readFileSync(filePath); + const eocdOffset = findEndOfCentralDirectory(buffer); + const totalEntries = buffer.readUInt16LE(eocdOffset + 10); + let offset = buffer.readUInt32LE(eocdOffset + 16); + const entries = new Map(); + + for (let index = 0; index < totalEntries; index += 1) { + if (buffer.readUInt32LE(offset) !== 0x02014b50) { + fail("VSIX central directory is malformed"); + } + + const method = buffer.readUInt16LE(offset + 10); + const compressedSize = buffer.readUInt32LE(offset + 20); + const uncompressedSize = buffer.readUInt32LE(offset + 24); + const fileNameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const localHeaderOffset = buffer.readUInt32LE(offset + 42); + const name = buffer.toString("utf8", offset + 46, offset + 46 + fileNameLength); + + entries.set(name, { + name, + method, + compressedSize, + uncompressedSize, + localHeaderOffset, + }); + + offset += 46 + fileNameLength + extraLength + commentLength; + } + + return { buffer, entries }; +} + +function extractEntry(zip, entryName) { + const entry = zip.entries.get(entryName); + if (!entry) { + fail(`VSIX is missing ${entryName}`); + } + + const offset = entry.localHeaderOffset; + if (zip.buffer.readUInt32LE(offset) !== 0x04034b50) { + fail(`VSIX local header for ${entryName} is malformed`); + } + + const fileNameLength = zip.buffer.readUInt16LE(offset + 26); + const extraLength = zip.buffer.readUInt16LE(offset + 28); + const dataStart = offset + 30 + fileNameLength + extraLength; + const compressed = zip.buffer.subarray(dataStart, dataStart + entry.compressedSize); + + if (entry.method === 0) { + return compressed; + } + + if (entry.method === 8) { + return inflateRawSync(compressed); + } + + fail(`VSIX entry ${entryName} uses unsupported compression method ${entry.method}`); +} + +function requireEntry(zip, entryName) { + if (!zip.entries.has(entryName)) { + fail(`VSIX is missing ${entryName}`); + } +} + +function requireAnyEntry(zip, entryNames, label) { + if (!entryNames.some((entryName) => zip.entries.has(entryName))) { + fail(`VSIX is missing ${label}: expected one of ${entryNames.join(", ")}`); + } +} + +function verifyVsix() { + try { + const stats = statSync(vsixPath); + if (!stats.isFile() || stats.size === 0) { + fail(`${vsixPath} is empty or not a file`); + } + } catch { + fail(`${vsixPath} does not exist`); + } + + const zip = readZipEntries(vsixPath); + const entryNames = [...zip.entries.keys()]; + + requireEntry(zip, "extension.vsixmanifest"); + requireEntry(zip, "extension/package.json"); + requireAnyEntry(zip, ["extension/readme.md", "extension/README.md"], "README"); + requireAnyEntry(zip, ["extension/LICENSE.txt", "extension/LICENSE"], "license"); + requireAnyEntry(zip, ["extension/changelog.md", "extension/CHANGELOG.md"], "changelog"); + + const main = String(packageJson.main || "").replace(/^\.\//, ""); + if (!main) { + fail("package.json does not define a main entry"); + } + requireEntry(zip, `extension/${main}`); + + if (packageJson.icon) { + requireEntry(zip, `extension/${packageJson.icon}`); + } + + const extensionPackage = JSON.parse(extractEntry(zip, "extension/package.json").toString("utf8")); + if (extensionPackage.version !== version) { + fail(`VSIX package version ${extensionPackage.version} does not match package.json version ${version}`); + } + + const forbiddenPatterns = [ + /^extension\/src\//, + /^extension\/test\//, + /^extension\/scripts\//, + /^extension\/\.github\//, + /^extension\/\.gitignore$/, + /^extension\/\.jumpjump\//, + /^extension\/\.trae\//, + /^extension\/\.vscode\//, + /^extension\/AGENTS\.md$/, + /^extension\/dist\//, + /^extension\/todo\.md$/, + /^extension\/tsconfig\.json$/, + /^extension\/package-lock\.json$/, + /^extension\/resources\/proto-jump\.svg$/, + /^extension\/out\/.*\.test\.js(?:\.map)?$/, + /^extension\/\.git(?:\/|$)/, + /^extension\/node_modules\//, + /(?:^|\/)\.DS_Store$/, + ]; + const forbiddenEntry = entryNames.find((entryName) => + forbiddenPatterns.some((pattern) => pattern.test(entryName)), + ); + + if (forbiddenEntry) { + fail(`VSIX contains unexpected file ${forbiddenEntry}`); + } + + pass(`VSIX ${vsixPath} exists and contains expected release files`); +} + +verifyReleaseRef(); +verifyChangelog(); + +if (!skipVsix) { + verifyVsix(); +} diff --git a/src/commands.ts b/src/commands.ts new file mode 100644 index 0000000..680d689 --- /dev/null +++ b/src/commands.ts @@ -0,0 +1,456 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +import * as vscode from 'vscode'; + +import { compileCurrentProto, testMakeProtoRule } from './compile'; +import { getUpdateTarget } from './config'; +import { diagnoseCurrentSymbol } from './diagnostics'; +import { + findGoUsagesPreferQualifiedName, + findProtoDefinitionPosition, + getGoUsagesForProtoPosition, + getProtoDefinitionNameAtCursor, + pickProtoDefinitionName, + ProtoGoDefinitionProvider, + clearGoUsageCaches, + registerGoUsageCacheInvalidation, + showReferencesNative +} from './goUsage'; +import { getStrings, getUiLanguage } from './i18n'; +import { goToProtoDefinition, provideGoDefinitionWithProtoFirst, resolveProtoDefinition } from './protoResolver'; +import { escapeHtml, isTextEditor } from './utils'; +import { ProtoJumpViewProvider } from './view'; + +export function activate(context: vscode.ExtensionContext): void { + const viewProvider = new ProtoJumpViewProvider(context.extensionUri); + const output = vscode.window.createOutputChannel('JumpProto'); + context.subscriptions.push(vscode.window.registerWebviewViewProvider('protoJump.view', viewProvider)); + context.subscriptions.push(output); + registerGoUsageCacheInvalidation(context); + + context.subscriptions.push( + vscode.languages.registerDefinitionProvider( + [{ language: 'proto' }, { language: 'proto3' }, { language: 'protobuf' }], + new ProtoGoDefinitionProvider() + ) + ); + + const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); + status.text = 'JumpProto'; + status.command = 'protoJump.goToProtoDefinition'; + context.subscriptions.push(status); + + const updateStatusVisibility = () => { + const editor = vscode.window.activeTextEditor; + if (editor && editor.document.languageId === 'go') { + status.show(); + } else { + status.hide(); + } + }; + updateStatusVisibility(); + + context.subscriptions.push( + vscode.commands.registerTextEditorCommand('protoJump.goToProtoDefinition', async editor => { + const strings = getStrings(); + const ok = await goToProtoDefinition(editor); + if (!ok) { + vscode.window.showInformationMessage(strings.resolveFailed); + } + }) + ); + + context.subscriptions.push( + vscode.languages.registerDefinitionProvider({ language: 'go' }, { + provideDefinition: provideGoDefinitionWithProtoFirst + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.openSettings', async () => { + const strings = getStrings(); + await vscode.commands.executeCommand('workbench.action.openSettingsJson'); + vscode.window.showInformationMessage(strings.openSettingsOpenedJson); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.addProtoRoot', async () => { + const strings = getStrings(); + const picked = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: true, + openLabel: strings.addProtoRoot + }); + if (!picked || picked.length === 0) return; + const config = vscode.workspace.getConfiguration('protoJump'); + const existing = (config.get('protoRoots') ?? []).filter(Boolean); + const next = Array.from(new Set([...existing, ...picked.map(u => u.fsPath)])); + await config.update('protoRoots', next, getUpdateTarget()); + viewProvider.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.removeProtoRoot', async (arg?: unknown) => { + const rootPath = + typeof arg === 'string' + ? arg + : typeof arg === 'object' && arg && 'meta' in (arg as any) && (arg as any).meta?.kind === 'protoRoot' + ? (arg as any).meta.rootPath + : undefined; + if (!rootPath) return; + const config = vscode.workspace.getConfiguration('protoJump'); + const existing = (config.get('protoRoots') ?? []).filter(Boolean); + const next = existing.filter(p => p !== rootPath); + await config.update('protoRoots', next, getUpdateTarget()); + viewProvider.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.toggleSearchInWorkspace', async () => { + const config = vscode.workspace.getConfiguration('protoJump'); + const current = config.get('searchInWorkspace') ?? true; + await config.update('searchInWorkspace', !current, getUpdateTarget()); + viewProvider.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.refreshView', () => viewProvider.refresh()) + ); + + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(() => { + updateStatusVisibility(); + viewProvider.refresh(); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.selectLanguage', async () => { + const strings = getStrings(); + const current = getUiLanguage(); + const picked = await vscode.window.showQuickPick( + [ + { label: strings.languageChinese, value: 'zh' as const }, + { label: strings.languageEnglish, value: 'en' as const } + ], + { title: strings.languageSelectTitle } + ); + if (!picked) return; + if (picked.value === current) return; + const config = vscode.workspace.getConfiguration('protoJump'); + await config.update('uiLanguage', picked.value, vscode.ConfigurationTarget.Global); + viewProvider.refresh(); + vscode.window.showInformationMessage(getStrings(picked.value).languageUpdated); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.editMakeProtoRule', async () => { + const strings = getStrings(); + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + await vscode.commands.executeCommand('workbench.action.openWorkspaceSettingsFile'); + } else { + await vscode.commands.executeCommand('workbench.action.openSettingsJson', 'protoJump.makeProtoCommand'); + } + vscode.window.showInformationMessage(strings.makeProtoRuleOpenedJson); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.openMakeProtoRuleHelp', () => openMakeProtoRuleHelp()) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.openOutput', () => { + output.show(true); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.testNavigation', () => testNavigation(output)) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.setMakeProtoRule', async (value?: unknown) => { + const strings = getStrings(); + const config = vscode.workspace.getConfiguration('protoJump'); + await config.update('makeProtoCommand', typeof value === 'string' ? value.trim() : '', getUpdateTarget()); + viewProvider.refresh(); + vscode.window.showInformationMessage(strings.makeProtoRuleSaved); + }) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.testMakeProtoRule', (value?: unknown) => testMakeProtoRule(value, output)) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.compileCurrentProto', () => compileCurrentProto(output)) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.diagnoseCurrentSymbol', () => diagnoseCurrentSymbol(output)) + ); + + context.subscriptions.push( + vscode.commands.registerCommand('protoJump.goToGoUsage', async (arg?: unknown) => { + const strings = getStrings(); + const active = (isTextEditor(arg) ? arg : vscode.window.activeTextEditor) ?? undefined; + if (!active) { + vscode.window.showInformationMessage(strings.protoDefinitionRequired); + return; + } + + const locations = await getGoUsagesForProtoPosition(active.document, active.selection.active, true); + if (locations) { + if (locations.length > 0) { + await showReferencesNative(active.document.uri, active.selection.active, locations); + } else { + vscode.window.showInformationMessage(strings.noGoUsagesFound); + } + return; + } + + let name = getProtoDefinitionNameAtCursor(active); + if (!name) { + name = await pickProtoDefinitionName(active, strings); + if (!name) { + vscode.window.showInformationMessage(strings.protoDefinitionRequired); + return; + } + const pos = findProtoDefinitionPosition(active.document, name); + if (pos) active.selection = new vscode.Selection(pos, pos); + } + + const matches = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: strings.searchingGoUsages, cancellable: true }, + (_progress, token) => findGoUsagesPreferQualifiedName(active.document, name!, token) + ); + + if (matches.length === 0) { + vscode.window.showInformationMessage(strings.noGoUsagesFound); + return; + } + + const matchLocs = matches.map(m => new vscode.Location(m.uri, m.range)); + await showReferencesNative(active.document.uri, active.selection.active, matchLocs); + }) + ); + + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('protoJump')) { + clearGoUsageCaches(); + viewProvider.refresh(); + } + }) + ); +} + +async function testNavigation(output: vscode.OutputChannel): Promise { + const strings = getStrings(); + const editor = vscode.window.activeTextEditor; + + output.clear(); + output.appendLine('JumpProto Test Navigation'); + output.appendLine(`Time: ${new Date().toISOString()}`); + + if (!editor) { + output.appendLine('Active editor: none'); + output.show(true); + vscode.window.showInformationMessage(strings.testNavigationNeedEditor); + return; + } + + const doc = editor.document; + const pos = editor.selection.active; + output.appendLine(`File: ${doc.uri.fsPath}`); + output.appendLine(`Language: ${doc.languageId}`); + output.appendLine(`Cursor: ${formatPosition(pos)}`); + + if (doc.languageId === 'go' || doc.uri.fsPath.endsWith('.go')) { + const resolved = await resolveProtoDefinition(doc, pos); + output.appendLine(''); + output.appendLine('[Go -> Proto]'); + if (resolved) { + output.appendLine(`Resolved: ${resolved.protoUri.fsPath}:${formatPosition(resolved.targetRange.start)}`); + output.show(true); + vscode.window.showInformationMessage(strings.testNavigationResolved); + return; + } + output.appendLine('Resolved: (not found)'); + output.show(true); + vscode.window.showInformationMessage(strings.testNavigationNoResult); + return; + } + + if (doc.uri.fsPath.endsWith('.proto')) { + output.appendLine(''); + output.appendLine('[Proto -> Go]'); + const directLocations = await getGoUsagesForProtoPosition(doc, pos, false); + if (directLocations && directLocations.length > 0) { + appendLocations(output, directLocations); + output.show(true); + vscode.window.showInformationMessage(strings.testNavigationResolved); + return; + } + + const name = getProtoDefinitionNameAtCursor(editor); + if (name) { + const usages = await findGoUsagesPreferQualifiedName(doc, name); + const locations = usages.map(usage => new vscode.Location(usage.uri, usage.range)); + if (locations.length > 0) { + output.appendLine(`Symbol: ${name}`); + appendLocations(output, locations); + output.show(true); + vscode.window.showInformationMessage(strings.testNavigationResolved); + return; + } + } + + output.appendLine('Resolved: (not found)'); + output.show(true); + vscode.window.showInformationMessage(strings.testNavigationNoResult); + return; + } + + output.appendLine(''); + output.appendLine('[Result]'); + output.appendLine('Unsupported editor. Open a Go or .proto file first.'); + output.show(true); + vscode.window.showInformationMessage(strings.testNavigationUnsupported); +} + +function appendLocations(output: vscode.OutputChannel, locations: vscode.Location[]): void { + output.appendLine(`Candidates: ${locations.length}`); + locations.slice(0, 20).forEach((loc, index) => { + output.appendLine(`- #${index + 1}: ${loc.uri.fsPath}:${formatPosition(loc.range.start)}`); + }); + if (locations.length > 20) { + output.appendLine(`... ${locations.length - 20} more`); + } +} + +function formatPosition(pos: vscode.Position): string { + return `${pos.line + 1}:${pos.character + 1}`; +} + +function openMakeProtoRuleHelp(): void { + const strings = getStrings(); + const placeholders = [ + { token: '{workspaceFolder}', desc: strings.makeProtoRuleHelpPlaceholderWorkspaceFolder }, + { token: '{protoSrcRoot}', desc: strings.makeProtoRuleHelpPlaceholderProtoSrcRoot }, + { token: '{protoFile}', desc: strings.makeProtoRuleHelpPlaceholderProtoFile }, + { token: '{protoFileNoExt}', desc: strings.makeProtoRuleHelpPlaceholderProtoFileNoExt }, + { token: '{protoDir}', desc: strings.makeProtoRuleHelpPlaceholderProtoDir }, + { token: '{relativeProto}', desc: strings.makeProtoRuleHelpPlaceholderRelativeProto }, + { token: '{relativeProtoNoExt}', desc: strings.makeProtoRuleHelpPlaceholderRelativeProtoNoExt }, + { token: '{protoPackage}', desc: strings.makeProtoRuleHelpPlaceholderProtoPackage } + ]; + + const panel = vscode.window.createWebviewPanel( + 'protoJump.makeProtoRuleHelp', + strings.makeProtoRuleHelpTitle, + vscode.ViewColumn.Active, + { enableScripts: false } + ); + + panel.webview.html = ` + + + + + + ${escapeHtml(strings.makeProtoRuleHelpTitle)} + + + +

${escapeHtml(strings.makeProtoRuleHelpTitle)}

+

${escapeHtml(strings.makeProtoRuleHelpIntro)}

+

${escapeHtml(strings.makeProtoRuleHelpQuickStartTitle)}

+
    +
  1. ${escapeHtml(strings.makeProtoRuleHelpQuickStartStep1)}
  2. +
  3. ${escapeHtml(strings.makeProtoRuleHelpQuickStartStep2)}
  4. +
  5. ${escapeHtml(strings.makeProtoRuleHelpQuickStartStep3)}
  6. +
+

${escapeHtml(strings.makeProtoRuleHelpUsageTitle)}

+

${escapeHtml(strings.makeProtoRuleHelpUsage)}

+

${escapeHtml(strings.makeProtoRuleHelpDemoTitle)}

+

${escapeHtml(strings.makeProtoRuleHelpDemoContext)}

+

${escapeHtml(strings.makeProtoRuleHelpDemoRuleLabel)}

+
cd {protoSrcRoot} && make special_proto packagename={protoPackage} filename={protoFileNoExt}
+

${escapeHtml(strings.makeProtoRuleHelpDemoResultLabel)}

+
cd /ABSOLUTE/PATH/TO/proto_src && make special_proto packagename=activity filename=user_profile
+

${escapeHtml(strings.makeProtoRuleHelpAdvancedDemoTitle)}

+

${escapeHtml(strings.makeProtoRuleHelpAdvancedDemoContext)}

+

${escapeHtml(strings.makeProtoRuleHelpAdvancedDemoRuleLabel)}

+
cd {protoSrcRoot} && case {relativeProto} in
+  rpc/*) make rpc pkg={protoFileNoExt} ;;
+  api/*) make api pkg={protoFileNoExt} ;;
+  model/*) make golang_model_proto ;;
+  *) make special_proto packagename={protoPackage} filename={protoFileNoExt} ;;
+esac
+

${escapeHtml(strings.makeProtoRuleHelpAdvancedDemoResultLabel)}

+
cd /ABSOLUTE/PATH/TO/proto_src && case rpc/user/get_user.proto in
+  rpc/*) make rpc pkg=get_user ;;
+  api/*) make api pkg=get_user ;;
+  model/*) make golang_model_proto ;;
+  *) make special_proto packagename=user filename=get_user ;;
+esac
+

${escapeHtml(strings.makeProtoRuleHelpPlaceholdersTitle)}

+ +

${escapeHtml(strings.makeProtoRuleHelpTipsTitle)}

+

${escapeHtml(strings.makeProtoRuleHelpTips)}

+

${escapeHtml(strings.makeProtoRuleHelpTroubleshootingTitle)}

+ + +`; +} diff --git a/src/compile.ts b/src/compile.ts new file mode 100644 index 0000000..639699d --- /dev/null +++ b/src/compile.ts @@ -0,0 +1,183 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +import * as path from 'node:path'; +import { execFile as execFileCb } from 'node:child_process'; +import { promisify } from 'node:util'; +import * as vscode from 'vscode'; + +import { getConfig } from './config'; +import { getStrings } from './i18n'; +import { resolveProtoSrcRootPath } from './pathResolver'; +import { normalizeSlashes } from './utils'; + +const execFile = promisify(execFileCb); + +export type ProtoCompileContext = { + workspaceFolder: string, + protoSrcRoot: string, + protoFile: string, + protoFileNoExt: string, + protoDir: string, + relativeProto: string, + relativeProtoNoExt: string, + protoPackage: string, +}; + +export function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export function resolveProtoSrcRoot(protoFile: string): string | undefined { + return resolveProtoSrcRootPath(protoFile, getConfig().protoRoots); +} + +export function resolveProtoCompileContext(doc: vscode.TextDocument): ProtoCompileContext | undefined { + if (!doc.uri.fsPath.endsWith('.proto')) return undefined; + const protoFile = doc.uri.fsPath; + const protoSrcRoot = resolveProtoSrcRoot(protoFile); + if (!protoSrcRoot) return undefined; + + const relativeProto = normalizeSlashes(path.relative(protoSrcRoot, protoFile)); + const workspaceFolder = vscode.workspace.getWorkspaceFolder(doc.uri)?.uri.fsPath ?? path.dirname(protoSrcRoot); + const text = doc.getText(); + const goPackageMatch = text.match(/^\s*option\s+go_package\s*=\s*"([^"]+)";/m); + const protoPackageMatch = text.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;/m); + const protoPackage = goPackageMatch + ? (goPackageMatch[1].split(';').pop()?.split('/').pop()?.trim() ?? '') + : (protoPackageMatch?.[1].split('.').pop()?.trim() ?? ''); + if (!protoPackage) return undefined; + + const protoFileNoExt = path.basename(protoFile, '.proto'); + return { + workspaceFolder, + protoSrcRoot, + protoFile, + protoFileNoExt, + protoDir: path.dirname(protoFile), + relativeProto, + relativeProtoNoExt: normalizeSlashes(relativeProto.replace(/\.proto$/, '')), + protoPackage, + }; +} + +export function getMakeProtoTemplateValues(ctx: ProtoCompileContext): Record { + return { + workspaceFolder: ctx.workspaceFolder, + protoSrcRoot: ctx.protoSrcRoot, + protoFile: ctx.protoFile, + protoFileNoExt: ctx.protoFileNoExt, + protoDir: ctx.protoDir, + relativeProto: ctx.relativeProto, + relativeProtoNoExt: ctx.relativeProtoNoExt, + protoPackage: ctx.protoPackage, + }; +} + +export function applyMakeProtoTemplate(template: string, ctx: ProtoCompileContext): string { + const values = getMakeProtoTemplateValues(ctx); + + let output = template; + for (const [key, value] of Object.entries(values)) { + output = output.replaceAll(`{${key}}`, shellQuote(value)); + } + return output; +} + +export function previewMakeProtoCommand(template: string, doc: vscode.TextDocument | undefined): { + rendered?: string; + reason?: 'empty' | 'noActiveProto' | 'unresolvedContext'; +} { + const rule = template.trim(); + if (!rule) return { reason: 'empty' }; + if (!doc || !doc.uri.fsPath.endsWith('.proto')) return { reason: 'noActiveProto' }; + + const compileCtx = resolveProtoCompileContext(doc); + if (!compileCtx) return { reason: 'unresolvedContext' }; + + return { rendered: applyMakeProtoTemplate(rule, compileCtx) }; +} + +export async function testMakeProtoRule(value: unknown, output: vscode.OutputChannel): Promise { + const strings = getStrings(); + const editor = vscode.window.activeTextEditor; + if (!editor || !editor.document.uri.fsPath.endsWith('.proto')) { + vscode.window.showInformationMessage(strings.testMakeProtoRuleNeedActiveProto); + return; + } + + const compileCtx = resolveProtoCompileContext(editor.document); + if (!compileCtx) { + vscode.window.showInformationMessage(strings.testMakeProtoRuleNeedActiveProto); + return; + } + + const rule = typeof value === 'string' ? value.trim() : ''; + if (!rule) { + vscode.window.showInformationMessage(strings.makeProtoRuleEmpty); + return; + } + + const rendered = applyMakeProtoTemplate(rule, compileCtx); + output.clear(); + output.appendLine(`[dry-run] ${rendered}`); + + try { + await execFile('/bin/zsh', ['-n', '-c', rendered], { + cwd: compileCtx.workspaceFolder, + maxBuffer: 10 * 1024 * 1024 + }); + vscode.window.showInformationMessage(strings.testMakeProtoRuleDone); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + output.appendLine(message); + output.show(true); + vscode.window.showErrorMessage(`${strings.testMakeProtoRuleFailed} ${message}`); + } +} + +export async function compileCurrentProto(output: vscode.OutputChannel): Promise { + const strings = getStrings(); + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showInformationMessage(strings.compileCurrentProtoInvalid); + return; + } + + const compileCtx = resolveProtoCompileContext(editor.document); + if (!compileCtx) { + vscode.window.showInformationMessage(strings.compileCurrentProtoInvalid); + return; + } + + let { makeProtoCommand } = getConfig(); + if (!makeProtoCommand) { + await vscode.commands.executeCommand('protoJump.editMakeProtoRule'); + makeProtoCommand = getConfig().makeProtoCommand; + if (!makeProtoCommand) return; + } + + const rendered = applyMakeProtoTemplate(makeProtoCommand, compileCtx); + output.clear(); + output.appendLine(`[command] ${rendered}`); + + try { + await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: strings.compilingCurrentProto, cancellable: false }, + async () => { + const result = await execFile('/bin/zsh', ['-lc', rendered], { + cwd: compileCtx.workspaceFolder, + maxBuffer: 10 * 1024 * 1024 + }); + if (result.stdout) output.appendLine(result.stdout.trimEnd()); + if (result.stderr) output.appendLine(result.stderr.trimEnd()); + } + ); + vscode.window.showInformationMessage(strings.compileCurrentProtoDone); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + output.appendLine(message); + output.show(true); + vscode.window.showErrorMessage(`${strings.compileCurrentProtoFailed} ${message}`); + } +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..23e89fc --- /dev/null +++ b/src/config.ts @@ -0,0 +1,57 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +export type ProtoJumpConfig = { + protoRoots: string[]; + searchInWorkspace: boolean; + makeProtoCommand: string; + exclude: string[]; +}; + +const DEFAULT_EXCLUDE = [ + '**/node_modules/**', + '**/vendor/**', + '**/out/**', + '**/dist/**', + '**/.git/**' +]; + +export function normalizeConfigPath(configPath: string): string { + const trimmed = configPath.trim(); + if (!trimmed) return ''; + const home = process.env.HOME; + const expanded = home + ? trimmed.replace(/^\$HOME(?=$|[\\/])/, home).replace(/^~(?=$|[\\/])/, home) + : trimmed; + return path.normalize(expanded); +} + +function normalizeExcludePattern(pattern: string): string { + return pattern.trim().replace(/\\/g, '/'); +} + +export function getConfig(): ProtoJumpConfig { + const config = vscode.workspace.getConfiguration('protoJump'); + const exclude = config.get('exclude'); + return { + protoRoots: (config.get('protoRoots') ?? []).map(normalizeConfigPath).filter(Boolean), + searchInWorkspace: config.get('searchInWorkspace') ?? true, + makeProtoCommand: (config.get('makeProtoCommand') ?? '').trim(), + exclude: (exclude && exclude.length > 0 ? exclude : DEFAULT_EXCLUDE) + .map(normalizeExcludePattern) + .filter(Boolean) + }; +} + +export function getWorkspaceExcludeGlob(config: ProtoJumpConfig = getConfig()): string | undefined { + return config.exclude.length > 0 ? `{${config.exclude.join(',')}}` : undefined; +} + +export function getUpdateTarget(): vscode.ConfigurationTarget { + return vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0 + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global; +} diff --git a/src/core.test.ts b/src/core.test.ts index 7aac3b4..e2683b4 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -48,3 +48,22 @@ test('findProtoSymbolMatch maps generated nested Go container names to proto mes assert.equal(m?.kind, 'field'); assert.equal(proto.slice(m!.startOffset, m!.endOffset), 'target_field'); }); + +test('findProtoSymbolMatch ignores declarations inside comments and strings', () => { + const proto = `// message Hidden {}\nmessage Visible {\n string note = 1 [json_name = "rpc Hidden {}"];\n}\n/* enum HiddenEnum { HIDDEN = 0; } */\n`; + assert.equal(findProtoSymbolMatch(proto, 'Hidden'), undefined); + assert.equal(findProtoSymbolMatch(proto, 'HiddenEnum'), undefined); + + const m = findProtoSymbolMatch(proto, 'Visible'); + assert.ok(m); + assert.equal(m?.kind, 'message'); + assert.equal(proto.slice(m!.startOffset, m!.endOffset), 'Visible'); +}); + +test('findProtoSymbolMatch tolerates braces in comments and strings while finding fields', () => { + const proto = `message Outer {\n // } should not close the message\n string note = 1 [json_name = "not_a_block_}"];\n string target_field = 2;\n}\n`; + const m = findProtoSymbolMatch(proto, 'TargetField', 'Outer'); + assert.ok(m); + assert.equal(m?.kind, 'field'); + assert.equal(proto.slice(m!.startOffset, m!.endOffset), 'target_field'); +}); diff --git a/src/core.ts b/src/core.ts index c27ea72..1505052 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,6 +1,8 @@ // Copyright 2026 JumpProto contributors. // SPDX-License-Identifier: Apache-2.0 +import { findProtoDeclarationSymbol, findProtoFieldSymbol } from './protoScanner'; + const PROTO_SOURCE_RE = /^\/\/\s*(?:source|Source):\s*(.+?\.proto)\s*$/m; export function extractProtoPathFromPbGo(generatedGoText: string): string | undefined { @@ -15,125 +17,11 @@ export type ProtoSymbolMatch = { kind: 'message' | 'enum' | 'rpc' | 'service' | 'field'; }; -type MessageBlock = { - name: string; - fullName: string; - startOffset: number; - bodyStartOffset: number; - bodyEndOffset: number; -}; - export function findProtoSymbolMatch(protoText: string, symbolName: string, containerName?: string): ProtoSymbolMatch | undefined { if (containerName) { - const fieldMatch = findFieldMatchInMessage(protoText, symbolName, containerName); + const fieldMatch = findProtoFieldSymbol(protoText, symbolName, containerName); if (fieldMatch) return fieldMatch; } - const patterns: Array<{ kind: ProtoSymbolMatch['kind']; re: RegExp }> = [ - { kind: 'message', re: new RegExp(`\\bmessage\\s+${escapeForRegex(symbolName)}\\b`, 'm') }, - { kind: 'enum', re: new RegExp(`\\benum\\s+${escapeForRegex(symbolName)}\\b`, 'm') }, - { kind: 'rpc', re: new RegExp(`\\brpc\\s+${escapeForRegex(symbolName)}\\b`, 'm') }, - { kind: 'service', re: new RegExp(`\\bservice\\s+${escapeForRegex(symbolName)}\\b`, 'm') } - ]; - - for (const { kind, re } of patterns) { - const m = re.exec(protoText); - if (!m || m.index === undefined) continue; - const kwLen = m[0].length - symbolName.length; - const startOffset = m.index + kwLen; - const endOffset = startOffset + symbolName.length; - return { startOffset, endOffset, kind }; - } - - return undefined; -} - -function findFieldMatchInMessage(protoText: string, symbolName: string, containerName: string): ProtoSymbolMatch | undefined { - const targetMessage = findMessageBlocks(protoText).find(block => block.fullName === containerName || block.name === containerName); - if (!targetMessage) return undefined; - - const nestedMessageRanges = findMessageBlocks(protoText) - .filter(block => block.startOffset > targetMessage.bodyStartOffset && block.bodyEndOffset < targetMessage.bodyEndOffset) - .map(block => ({ start: block.startOffset, end: block.bodyEndOffset + 1 })); - - const msgBody = protoText.slice(targetMessage.bodyStartOffset, targetMessage.bodyEndOffset); - const fieldRe = /\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\d+/g; - let fm: RegExpExecArray | null; - while ((fm = fieldRe.exec(msgBody)) !== null) { - const start = targetMessage.bodyStartOffset + fm.index; - if (nestedMessageRanges.some(range => start >= range.start && start < range.end)) continue; - - const protoFieldName = fm[1]; - if ( - protoFieldName === symbolName || - protoFieldName.toLowerCase() === symbolName.toLowerCase() || - toGoExportedName(protoFieldName) === symbolName - ) { - return { - startOffset: start, - endOffset: start + protoFieldName.length, - kind: 'field' - }; - } - } - - return undefined; -} - -function findMessageBlocks(protoText: string): MessageBlock[] { - const declRe = /\bmessage\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{/g; - const rawBlocks: Array> = []; - let match: RegExpExecArray | null; - - while ((match = declRe.exec(protoText)) !== null) { - const openBrace = protoText.indexOf('{', match.index); - if (openBrace < 0) continue; - const closeBrace = findMatchingBrace(protoText, openBrace); - if (closeBrace === undefined) continue; - - rawBlocks.push({ - name: match[1], - startOffset: match.index, - bodyStartOffset: openBrace + 1, - bodyEndOffset: closeBrace - }); - } - - const blocks: MessageBlock[] = rawBlocks.map(block => ({ ...block, fullName: block.name })); - for (const block of blocks) { - const parent = blocks - .filter(candidate => candidate.startOffset < block.startOffset && candidate.bodyEndOffset > block.startOffset) - .sort((a, b) => b.startOffset - a.startOffset)[0]; - block.fullName = parent ? `${parent.fullName}_${block.name}` : block.name; - } - return blocks; -} - -function findMatchingBrace(text: string, openBraceOffset: number): number | undefined { - let depth = 0; - for (let i = openBraceOffset; i < text.length; i += 1) { - const ch = text[i]; - if (ch === '{') depth += 1; - if (ch === '}') { - depth -= 1; - if (depth === 0) return i; - } - } - return undefined; -} - -function escapeForRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function toGoExportedName(protoName: string): string { - const parts = protoName.split('_').filter(Boolean); - let out = ''; - for (let i = 0; i < parts.length; i += 1) { - const seg = parts[i]; - const mapped = seg.length === 0 ? seg : seg[0].toUpperCase() + seg.slice(1); - if (i > 0 && /^\d/.test(seg)) out += '_'; - out += mapped; - } - return out; + return findProtoDeclarationSymbol(protoText, symbolName); } diff --git a/src/diagnostics.ts b/src/diagnostics.ts new file mode 100644 index 0000000..e7abd05 --- /dev/null +++ b/src/diagnostics.ts @@ -0,0 +1,191 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import { getConfig, getWorkspaceExcludeGlob } from './config'; +import { extractProtoPathFromPbGo } from './core'; +import { getGoUsagesForProtoPosition } from './goUsage'; +import { parseGoPackageInfo } from './goText'; +import { getStrings } from './i18n'; +import { resolveProtoDefinition } from './protoResolver'; +import { escapeForGlob, normalizeSlashes } from './utils'; + +export async function diagnoseCurrentSymbol(output: vscode.OutputChannel): Promise { + const strings = getStrings(); + const editor = vscode.window.activeTextEditor; + output.clear(); + output.appendLine('JumpProto Diagnostics'); + output.appendLine(`Time: ${new Date().toISOString()}`); + + if (!editor) { + output.appendLine('Active editor: none'); + output.show(true); + vscode.window.showInformationMessage(strings.diagnoseCurrentSymbolDone); + return; + } + + const doc = editor.document; + const pos = editor.selection.active; + const wordRange = doc.getWordRangeAtPosition(pos, /[A-Za-z_][A-Za-z0-9_]*/); + const symbol = wordRange ? doc.getText(wordRange) : ''; + const config = getConfig(); + + output.appendLine(''); + output.appendLine('[Editor]'); + output.appendLine(`Language: ${doc.languageId}`); + output.appendLine(`File: ${doc.uri.fsPath}`); + output.appendLine(`Cursor: ${pos.line + 1}:${pos.character + 1}`); + output.appendLine(`Symbol: ${symbol || '(none)'}`); + output.appendLine(`Workspace: ${vscode.workspace.getWorkspaceFolder(doc.uri)?.uri.fsPath ?? '(none)'}`); + + output.appendLine(''); + output.appendLine('[Config]'); + output.appendLine(`protoRoots: ${config.protoRoots.length > 0 ? config.protoRoots.join(', ') : '(empty)'}`); + output.appendLine(`searchInWorkspace: ${config.searchInWorkspace}`); + output.appendLine(`exclude: ${config.exclude.length > 0 ? config.exclude.join(', ') : '(empty)'}`); + output.appendLine(`makeProtoCommand: ${config.makeProtoCommand ? '(configured)' : '(empty)'}`); + + if (doc.uri.fsPath.endsWith('.proto')) { + await diagnoseProtoEditor(doc, pos, output); + } else if (doc.languageId === 'go' || doc.uri.fsPath.endsWith('.go')) { + await diagnoseGoEditor(doc, pos, output); + } else { + output.appendLine(''); + output.appendLine('[Result]'); + output.appendLine('This command is most useful in Go or .proto files.'); + } + + output.show(true); + vscode.window.showInformationMessage(strings.diagnoseCurrentSymbolDone); +} + +async function diagnoseProtoEditor( + doc: vscode.TextDocument, + pos: vscode.Position, + output: vscode.OutputChannel +): Promise { + const protoText = doc.getText(); + const goPackage = parseGoPackageInfo(protoText); + const usages = await getGoUsagesForProtoPosition(doc, pos, false); + + output.appendLine(''); + output.appendLine('[Proto]'); + output.appendLine(`go_package packageName: ${goPackage?.packageName ?? '(not found)'}`); + output.appendLine(`go_package importPath: ${goPackage?.importPath ?? '(not found)'}`); + output.appendLine('Usage strategy: cached workspace scan + proto scanner + Go token scanner + import alias + same-package bare name + structured field access'); + output.appendLine(`Usage candidates: ${usages?.length ?? 0}`); +} + +async function diagnoseGoEditor( + doc: vscode.TextDocument, + pos: vscode.Position, + output: vscode.OutputChannel +): Promise { + const defs = await vscode.commands.executeCommand( + 'vscode.executeDefinitionProvider', + doc.uri, + pos + ); + const locations = (defs ?? []).map(d => 'targetUri' in d + ? new vscode.Location((d as vscode.LocationLink).targetUri, (d as vscode.LocationLink).targetSelectionRange ?? (d as vscode.LocationLink).targetRange) + : (d as vscode.Location)); + const pbGoLocations = locations.filter(loc => loc.uri.fsPath.endsWith('.pb.go') || loc.uri.fsPath.endsWith('.pb.gw.go')); + + output.appendLine(''); + output.appendLine('[Go Definition Provider]'); + output.appendLine(`Total definitions: ${locations.length}`); + output.appendLine(`Generated Go definitions: ${pbGoLocations.length}`); + locations.slice(0, 20).forEach((loc, index) => { + output.appendLine(`- #${index + 1}: ${loc.uri.fsPath}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`); + }); + + for (const loc of pbGoLocations.slice(0, 5)) { + await diagnoseGeneratedGoLocation(loc, output); + } + + const resolved = await resolveProtoDefinition(doc, pos); + output.appendLine(''); + output.appendLine('[JumpProto Resolution]'); + if (resolved) { + output.appendLine(`Resolved proto: ${resolved.protoUri.fsPath}:${resolved.targetRange.start.line + 1}:${resolved.targetRange.start.character + 1}`); + } else { + output.appendLine('Resolved proto: (not found)'); + } +} + +async function diagnoseGeneratedGoLocation(loc: vscode.Location, output: vscode.OutputChannel): Promise { + output.appendLine(''); + output.appendLine('[Generated Go]'); + output.appendLine(`File: ${loc.uri.fsPath}`); + + let text: string; + try { + text = fs.readFileSync(loc.uri.fsPath, 'utf8'); + } catch (error) { + output.appendLine(`Read failed: ${error instanceof Error ? error.message : String(error)}`); + return; + } + + const source = extractProtoPathFromPbGo(text); + output.appendLine(`source header: ${source ?? '(not found)'}`); + if (!source) return; + + const candidates = await resolveProtoCandidates(source); + output.appendLine(`proto candidates: ${candidates.length}`); + candidates.forEach((candidate, index) => { + output.appendLine(`- #${index + 1}: ${candidate.fsPath} (${candidate.exists ? 'exists' : 'missing'}, via ${candidate.via})`); + }); +} + +async function resolveProtoCandidates(protoPathFromPbGo: string): Promise> { + const candidates: Array<{ fsPath: string; exists: boolean; via: string }> = []; + const config = getConfig(); + + if (path.isAbsolute(protoPathFromPbGo)) { + candidates.push({ + fsPath: protoPathFromPbGo, + exists: fs.existsSync(protoPathFromPbGo), + via: 'absolute source' + }); + } + + for (const root of config.protoRoots) { + const full = path.join(root, protoPathFromPbGo); + candidates.push({ + fsPath: full, + exists: fs.existsSync(full), + via: 'protoRoots' + }); + } + + if (config.searchInWorkspace) { + const glob = `**/${escapeForGlob(protoPathFromPbGo)}`; + const matches = await vscode.workspace.findFiles(glob, getWorkspaceExcludeGlob(config), 5); + for (const match of matches) { + candidates.push({ + fsPath: match.fsPath, + exists: true, + via: `workspace glob ${normalizeSlashes(glob)}` + }); + } + } + + return dedupeCandidates(candidates); +} + +function dedupeCandidates( + candidates: Array<{ fsPath: string; exists: boolean; via: string }> +): Array<{ fsPath: string; exists: boolean; via: string }> { + const out: Array<{ fsPath: string; exists: boolean; via: string }> = []; + const seen = new Set(); + for (const candidate of candidates) { + const key = path.normalize(candidate.fsPath); + if (seen.has(key)) continue; + seen.add(key); + out.push(candidate); + } + return out; +} diff --git a/src/extension.ts b/src/extension.ts index bb88f69..3e28330 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,1447 +1,12 @@ // Copyright 2026 JumpProto contributors. // SPDX-License-Identifier: Apache-2.0 -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { execFile as execFileCb } from 'node:child_process'; -import { promisify } from 'node:util'; import * as vscode from 'vscode'; -import { extractProtoPathFromPbGo, findProtoSymbolMatch } from './core'; -import { ProtoJumpViewProvider } from './view'; -import { getStrings, getUiLanguage } from './i18n'; - -type ResolveResult = { - protoUri: vscode.Uri; - targetRange: vscode.Range; -}; - -type GoPackageInfo = { - packageName: string; - importPath?: string; -}; - -type GoUsage = { - uri: vscode.Uri; - range: vscode.Range; - preview: string; -}; - -const resolvingKeys = new Set(); -const execFile = promisify(execFileCb); - -function makeResolveKey(uri: vscode.Uri, position: vscode.Position): string { - return `${uri.toString()}::${position.line}:${position.character}`; -} - -function getConfig() { - const config = vscode.workspace.getConfiguration('protoJump'); - return { - protoRoots: (config.get('protoRoots') ?? []).map(normalizeConfigPath).filter(Boolean), - searchInWorkspace: config.get('searchInWorkspace') ?? true, - makeProtoCommand: (config.get('makeProtoCommand') ?? '').trim() - }; -} - -type ProtoCompileContext = { - workspaceFolder: string, - protoSrcRoot: string, - protoFile: string, - protoFileNoExt: string, - protoDir: string, - relativeProto: string, - relativeProtoNoExt: string, - protoPackage: string, -}; - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'\\''`)}'`; -} - -function normalizeConfigPath(configPath: string): string { - const trimmed = configPath.trim(); - if (!trimmed) return ''; - const home = process.env.HOME; - const expanded = home - ? trimmed.replace(/^\$HOME(?=$|[\\/])/, home).replace(/^~(?=$|[\\/])/, home) - : trimmed; - return path.normalize(expanded); -} - -function resolveProtoSrcRoot(protoFile: string): string | undefined { - const normalizedProtoFile = path.normalize(protoFile); - const matchingConfiguredRoots = getConfig().protoRoots - .filter(root => { - const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; - return normalizedProtoFile === root || normalizedProtoFile.startsWith(rootWithSep); - }) - .sort((a, b) => b.length - a.length); - if (matchingConfiguredRoots.length > 0) return matchingConfiguredRoots[0]; - - let current = path.dirname(protoFile); - while (true) { - if (path.basename(current) === 'proto_src' && fs.existsSync(path.join(current, 'Makefile'))) { - return current; - } - const parent = path.dirname(current); - if (parent === current) return undefined; - current = parent; - } -} - -function resolveProtoCompileContext(doc: vscode.TextDocument): ProtoCompileContext | undefined { - if (!doc.uri.fsPath.endsWith('.proto')) return undefined; - const protoFile = doc.uri.fsPath; - const protoSrcRoot = resolveProtoSrcRoot(protoFile); - if (!protoSrcRoot) return undefined; - - const relativeProto = normalizeSlashes(path.relative(protoSrcRoot, protoFile)); - const workspaceFolder = vscode.workspace.getWorkspaceFolder(doc.uri)?.uri.fsPath ?? path.dirname(protoSrcRoot); - const text = doc.getText(); - const goPackageMatch = text.match(/^\s*option\s+go_package\s*=\s*"([^"]+)";/m); - const protoPackageMatch = text.match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;/m); - const protoPackage = goPackageMatch - ? (goPackageMatch[1].split(';').pop()?.split('/').pop()?.trim() ?? '') - : (protoPackageMatch?.[1].split('.').pop()?.trim() ?? ''); - if (!protoPackage) return undefined; - - const protoFileNoExt = path.basename(protoFile, '.proto'); - return { - workspaceFolder, - protoSrcRoot, - protoFile, - protoFileNoExt, - protoDir: path.dirname(protoFile), - relativeProto, - relativeProtoNoExt: normalizeSlashes(relativeProto.replace(/\.proto$/, '')), - protoPackage, - }; -} - -function applyMakeProtoTemplate(template: string, ctx: ProtoCompileContext): string { - const values: Record = { - workspaceFolder: ctx.workspaceFolder, - protoSrcRoot: ctx.protoSrcRoot, - protoFile: ctx.protoFile, - protoFileNoExt: ctx.protoFileNoExt, - protoDir: ctx.protoDir, - relativeProto: ctx.relativeProto, - relativeProtoNoExt: ctx.relativeProtoNoExt, - protoPackage: ctx.protoPackage, - }; - - let output = template; - for (const [key, value] of Object.entries(values)) { - output = output.replaceAll(`{${key}}`, shellQuote(value)); - } - return output; -} - -function escapeForGlob(p: string): string { - return p.replaceAll('\\', '/'); -} - -function normalizeSlashes(p: string): string { - return p.replaceAll('\\', '/'); -} - -function escapeForRegex(s: string): string { - return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function escapeHtml(value: string): string { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -function isTextEditor(arg: unknown): arg is vscode.TextEditor { - return !!arg && typeof arg === 'object' && 'document' in (arg as any) && 'selection' in (arg as any); -} - -function countChar(s: string, ch: string): number { - let n = 0; - for (let i = 0; i < s.length; i += 1) { - if (s[i] === ch) n += 1; - } - return n; -} - -function buildProtoSourceCandidates(protoFsPath: string): string[] { - const { protoRoots } = getConfig(); - const candidates = new Set(); - candidates.add(normalizeSlashes(vscode.workspace.asRelativePath(protoFsPath, false))); - for (const root of protoRoots) { - const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; - if (protoFsPath.startsWith(rootWithSep)) { - candidates.add(normalizeSlashes(path.relative(root, protoFsPath))); - } - } - return Array.from(candidates).filter(Boolean); -} - -function parseGoPackageInfo(protoText: string): GoPackageInfo | undefined { - const goPackageMatch = protoText.match(/^\s*option\s+go_package\s*=\s*"([^"]+)";/m); - if (!goPackageMatch) return undefined; - - const value = goPackageMatch[1].trim(); - if (!value) return undefined; - if (value.includes(';')) { - const [importPath, packageName] = value.split(';'); - const trimmedPackageName = packageName?.trim(); - if (!trimmedPackageName) return undefined; - return { - packageName: trimmedPackageName, - importPath: importPath.trim() || undefined - }; - } - - const parts = value.split('/'); - const packageName = parts[parts.length - 1]?.trim(); - if (!packageName) return undefined; - return { - packageName, - importPath: value.includes('/') ? value : undefined - }; -} - -async function resolveGoPackageInfoForProtoFile(protoDoc: vscode.TextDocument): Promise { - const strictCandidates = buildProtoSourceCandidates(protoDoc.uri.fsPath); - const basenameCandidate = normalizeSlashes(path.basename(protoDoc.uri.fsPath)); - const exclude = '**/{node_modules,vendor,out,dist,.git}/**'; - const pbGos = await vscode.workspace.findFiles('**/*.pb.go', exclude, 5000); - const basenameMatchedPackages: string[] = []; - const declaredInfo = parseGoPackageInfo(protoDoc.getText()); - - for (const uri of pbGos) { - let header: string; - try { - header = fs.readFileSync(uri.fsPath, 'utf8').slice(0, 6000); - } catch { - continue; - } - const sourceMatch = header.match(/^\/\/\s*source:\s*(.+)\s*$/m); - if (!sourceMatch) continue; - const source = normalizeSlashes(sourceMatch[1].trim()); - if (!strictCandidates.includes(source)) { - if (source === basenameCandidate) { - const pkgMatch = header.match(/^package\s+([A-Za-z_][A-Za-z0-9_]*)/m); - if (pkgMatch) basenameMatchedPackages.push(pkgMatch[1]); - } - continue; - } - - const pkgMatch = header.match(/^package\s+([A-Za-z_][A-Za-z0-9_]*)/m); - if (pkgMatch) return { packageName: pkgMatch[1], importPath: declaredInfo?.importPath }; - } - - if (basenameMatchedPackages.length === 1) { - return { packageName: basenameMatchedPackages[0], importPath: declaredInfo?.importPath }; - } - - if (declaredInfo) return declaredInfo; - - const protoPackageMatch = protoDoc.getText().match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;/m); - if (protoPackageMatch) { - const seg = protoPackageMatch[1].split('.').pop()?.trim(); - if (seg) return { packageName: seg }; - } - - return undefined; -} - -async function resolveGoPackageForProtoFile(protoDoc: vscode.TextDocument): Promise { - return (await resolveGoPackageInfoForProtoFile(protoDoc))?.packageName; -} - -function getProtoDefinitionNameAtPosition(doc: vscode.TextDocument, pos: vscode.Position): string | undefined { - if (!doc.uri.fsPath.endsWith('.proto')) return undefined; - const wordRange = doc.getWordRangeAtPosition(pos, /[A-Za-z_][A-Za-z0-9_]*/); - if (!wordRange) return undefined; - const name = doc.getText(wordRange); - if (!name) return undefined; - const line = doc.lineAt(pos.line).text; - const re = new RegExp(`\\b(message|enum|service|rpc)\\s+${escapeForRegex(name)}\\b`); - if (!re.test(line)) return undefined; - return name; -} - -function getProtoDefinitionNameAtCursor(editor: vscode.TextEditor): string | undefined { - return getProtoDefinitionNameAtPosition(editor.document, editor.selection.active); -} - -function toGoExportedName(protoName: string): string { - const parts = protoName.split('_').filter(Boolean); - let out = ''; - for (let i = 0; i < parts.length; i += 1) { - const seg = parts[i]; - const mapped = seg.length === 0 ? seg : seg[0].toUpperCase() + seg.slice(1); - if (i > 0 && /^\d/.test(seg)) out += '_'; - out += mapped; - } - return out; -} - -function getEnclosingProtoMessageNameAtLine(doc: vscode.TextDocument, lineIndex: number): string | undefined { - const declRe = /^\s*message\s+([A-Za-z_][A-Za-z0-9_]*)\b/; - const stack: Array<{ name: string; startDepth: number }> = []; - let depth = 0; - let pending: { name: string; depth: number } | undefined; - - const finalizePendingIfNeeded = (line: string) => { - if (!pending) return; - const braceIdx = line.indexOf('{'); - if (braceIdx === -1) return; - const before = line.slice(0, braceIdx); - if (declRe.test(before) || declRe.test(line)) { - depth += 1; - stack.push({ name: pending.name, startDepth: depth }); - pending = undefined; - depth += countChar(line.slice(braceIdx + 1), '{'); - depth -= countChar(line.slice(braceIdx + 1), '}'); - } - }; - - for (let i = 0; i <= lineIndex && i < doc.lineCount; i += 1) { - const line = doc.lineAt(i).text; - const m = declRe.exec(line); - if (m) pending = { name: m[1], depth }; - - const open = countChar(line, '{'); - const close = countChar(line, '}'); - if (pending && open > 0) { - finalizePendingIfNeeded(line); - } else { - depth += open; - depth -= close; - } - - while (stack.length > 0 && depth < stack[stack.length - 1].startDepth) { - stack.pop(); - } - } - - return stack.length > 0 ? stack[stack.length - 1].name : undefined; -} - -function getProtoFieldContextAtPosition( - doc: vscode.TextDocument, - pos: vscode.Position -): { kind: 'fieldName'; fieldName: string; messageName?: string } | { kind: 'fieldType'; typeName: string } | undefined { - if (!doc.uri.fsPath.endsWith('.proto')) return undefined; - const wordRange = doc.getWordRangeAtPosition(pos, /[A-Za-z_][A-Za-z0-9_]*/); - if (!wordRange) return undefined; - const word = doc.getText(wordRange); - if (!word) return undefined; - const line = doc.lineAt(pos.line).text; - - const fieldDeclRe = /^\s*(?:repeated|optional)?\s*(.+?)\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\d+\b/; - const m = fieldDeclRe.exec(line); - if (!m) return undefined; - const typePart = m[1].trim(); - const fieldName = m[2]; - - if (word === fieldName) { - const messageName = getEnclosingProtoMessageNameAtLine(doc, pos.line); - return { kind: 'fieldName', fieldName, messageName }; - } - - const primitives = new Set([ - 'double', - 'float', - 'int32', - 'int64', - 'uint32', - 'uint64', - 'sint32', - 'sint64', - 'fixed32', - 'fixed64', - 'sfixed32', - 'sfixed64', - 'bool', - 'string', - 'bytes' - ]); - - let typeName = typePart; - if (typeName.startsWith('map<')) { - const mm = /^map<[^,]+,\s*([A-Za-z_][A-Za-z0-9_\.]*)\s*>/.exec(typeName); - if (mm) typeName = mm[1]; - } - typeName = typeName.split(/\s+/).pop() ?? typeName; - typeName = typeName.split('.').pop() ?? typeName; - if (!typeName || primitives.has(typeName)) return undefined; - - if (word === typeName) return { kind: 'fieldType', typeName }; - return undefined; -} - -async function pickProtoDefinitionName(editor: vscode.TextEditor, strings: ReturnType): Promise { - const doc = editor.document; - if (!doc.uri.fsPath.endsWith('.proto')) return undefined; - - const regex = /^\s*(message|enum|service|rpc)\s+([A-Za-z_][A-Za-z0-9_]*)\b/; - const candidates: Array<{ kind: string; name: string; line: number }> = []; - - const symbols = await vscode.commands.executeCommand('vscode.executeDocumentSymbolProvider', doc.uri); - const pushSymbol = (kind: string, name: string) => { - if (!name) return; - candidates.push({ kind, name, line: -1 }); - }; - const walk = (items: any[]) => { - for (const it of items) { - if (!it) continue; - if (typeof it.name === 'string') { - pushSymbol(typeof it.kind === 'number' ? String(it.kind) : 'symbol', it.name); - } - if (Array.isArray(it.children) && it.children.length > 0) walk(it.children); - } - }; - if (Array.isArray(symbols)) walk(symbols as any[]); - - if (candidates.length === 0) { - for (let i = 0; i < doc.lineCount; i += 1) { - const line = doc.lineAt(i).text; - const m = regex.exec(line); - if (!m) continue; - candidates.push({ kind: m[1], name: m[2], line: i }); - } - } - - const seen = new Set(); - const items = candidates - .filter(c => { - if (seen.has(c.name)) return false; - seen.add(c.name); - return true; - }) - .slice(0, 500) - .map(c => ({ - label: c.name, - description: c.kind, - line: c.line - })); - - const picked = await vscode.window.showQuickPick(items, { - title: strings.pickProtoDefinitionTitle, - placeHolder: strings.pickProtoDefinitionPlaceholder, - matchOnDescription: true - }); - if (!picked) return undefined; - return picked.label; -} - -function findProtoDefinitionPosition(doc: vscode.TextDocument, name: string): vscode.Position | undefined { - const re = new RegExp(`\\b(message|enum|service|rpc)\\s+${escapeForRegex(name)}\\b`); - for (let i = 0; i < doc.lineCount; i += 1) { - const line = doc.lineAt(i).text; - const m = re.exec(line); - if (!m || m.index === undefined) continue; - const idx = line.indexOf(name, m.index); - if (idx >= 0) return new vscode.Position(i, idx); - } - return undefined; -} - -function findProtoSymbolLocationsInDocument( - doc: vscode.TextDocument, - symbolName: string -): vscode.Location[] { - const escaped = escapeForRegex(symbolName); - const re = new RegExp(`\\b${escaped}\\b`, 'g'); - const locations: vscode.Location[] = []; - for (let i = 0; i < doc.lineCount; i += 1) { - const line = doc.lineAt(i).text; - re.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = re.exec(line)) !== null) { - const idx = m.index ?? 0; - locations.push( - new vscode.Location( - doc.uri, - new vscode.Range(new vscode.Position(i, idx), new vscode.Position(i, idx + symbolName.length)) - ) - ); - if ((m[0] ?? '').length === 0) break; - } - } - return locations; -} - -function mergeLocations(primary: vscode.Location[], secondary: vscode.Location[]): vscode.Location[] { - const out: vscode.Location[] = []; - const seen = new Set(); - for (const loc of [...primary, ...secondary]) { - const key = `${loc.uri.toString()}#${loc.range.start.line}:${loc.range.start.character}-${loc.range.end.line}:${loc.range.end.character}`; - if (seen.has(key)) continue; - seen.add(key); - out.push(loc); - } - return out; -} - -function goUsageKey(usage: GoUsage): string { - return `${usage.uri.toString()}#${usage.range.start.line}:${usage.range.start.character}-${usage.range.end.line}:${usage.range.end.character}`; -} - -function mergeGoUsageResults(...groups: GoUsage[][]): GoUsage[] { - const out: GoUsage[] = []; - const seen = new Set(); - for (const group of groups) { - for (const usage of group) { - const key = goUsageKey(usage); - if (seen.has(key)) continue; - seen.add(key); - out.push(usage); - } - } - return out; -} - -async function showReferencesNative( - sourceUri: vscode.Uri, - sourcePos: vscode.Position, - locations: vscode.Location[] -): Promise { - await vscode.commands.executeCommand('editor.action.showReferences', sourceUri, sourcePos, locations); -} - -function collectRegexMatchesInText( - uri: vscode.Uri, - text: string, - regexes: RegExp[], - results: GoUsage[], - maxResults: number -): boolean { - const lines = text.split('\n'); - for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { - const line = lines[lineIndex]; - for (const re of regexes) { - re.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = re.exec(line)) !== null) { - const start = m.index ?? 0; - const length = m[0]?.length ?? 0; - results.push({ - uri, - range: new vscode.Range( - new vscode.Position(lineIndex, start), - new vscode.Position(lineIndex, start + length) - ), - preview: line.replace(/\s+/g, ' ').trim() - }); - if (results.length >= maxResults) return true; - if (length === 0) break; - } - if (results.length >= maxResults) return true; - } - } - return false; -} - -async function findGoUsagesInWorkspaceByRegexes( - regexes: RegExp[], - maxResults: number, - filePredicate?: (uri: vscode.Uri, text: string) => boolean -): Promise { - const exclude = '**/{node_modules,vendor,out,dist,.git}/**'; - const results: GoUsage[] = []; - const uris = await vscode.workspace.findFiles('**/*.go', exclude, 5000); - - for (const uri of uris) { - const base = path.basename(uri.fsPath); - if (base.endsWith('.pb.go') || base.endsWith('.pb.gw.go') || base.includes('.pb.')) continue; - let text: string; - try { - text = fs.readFileSync(uri.fsPath, 'utf8'); - } catch { - continue; - } - if (filePredicate && !filePredicate(uri, text)) continue; - if (collectRegexMatchesInText(uri, text, regexes, results, maxResults)) return results; - } - - return results; -} - -async function findGoUsagesInWorkspace( - symbolName: string, - filePredicate?: (uri: vscode.Uri, text: string) => boolean -): Promise { - const re = new RegExp(`\\b${escapeForRegex(symbolName)}\\b`, 'g'); - return findGoUsagesInWorkspaceByRegexes([re], 200, filePredicate); -} - -function findImportAliases(goText: string, importPath: string, packageName: string): string[] { - const aliases = new Set(); - const addAlias = (rawAlias: string | undefined, rawPath: string | undefined) => { - if (rawPath !== importPath) return; - const alias = rawAlias?.trim(); - if (!alias) { - aliases.add(packageName); - return; - } - if (alias === '_' || alias === '.') return; - aliases.add(alias); - }; - - const singleImportRe = /^\s*import\s+(?:(\w+|\.|_)\s+)?"([^"]+)"/gm; - let singleMatch: RegExpExecArray | null; - while ((singleMatch = singleImportRe.exec(goText)) !== null) { - addAlias(singleMatch[1], singleMatch[2]); - } - - const importBlockRe = /^\s*import\s*\(([\s\S]*?)^\s*\)/gm; - let blockMatch: RegExpExecArray | null; - while ((blockMatch = importBlockRe.exec(goText)) !== null) { - const body = blockMatch[1]; - const lineRe = /^\s*(?:(\w+|\.|_)\s+)?"([^"]+)"/gm; - let lineMatch: RegExpExecArray | null; - while ((lineMatch = lineRe.exec(body)) !== null) { - addAlias(lineMatch[1], lineMatch[2]); - } - } - - return Array.from(aliases); -} - -async function findGoImportAliasUsages( - goPkg: GoPackageInfo, - symbolName: string, - maxResults: number -): Promise { - if (!goPkg.importPath) return []; - - const exclude = '**/{node_modules,vendor,out,dist,.git}/**'; - const results: GoUsage[] = []; - const uris = await vscode.workspace.findFiles('**/*.go', exclude, 5000); - - for (const uri of uris) { - const base = path.basename(uri.fsPath); - if (base.endsWith('.pb.go') || base.endsWith('.pb.gw.go') || base.includes('.pb.')) continue; - let text: string; - try { - text = fs.readFileSync(uri.fsPath, 'utf8'); - } catch { - continue; - } - if (!text.includes(goPkg.importPath) || !text.includes(symbolName)) continue; - - const aliases = findImportAliases(text, goPkg.importPath, goPkg.packageName); - if (aliases.length === 0) continue; - const regexes = aliases.map(alias => new RegExp(`\\b${escapeForRegex(alias)}\\.${escapeForRegex(symbolName)}\\b`, 'g')); - if (collectRegexMatchesInText(uri, text, regexes, results, maxResults)) return results; - } - - return results; -} - -async function findGoUsagesPreferQualifiedName( - protoDoc: vscode.TextDocument, - symbolName: string -): Promise { - const goPkg = await resolveGoPackageInfoForProtoFile(protoDoc); - let exactMatches: GoUsage[] = []; - let aliasMatches: GoUsage[] = []; - if (goPkg) { - const qualifiedName = `${goPkg.packageName}.${symbolName}`; - exactMatches = await findGoUsagesInWorkspaceByRegexes( - [new RegExp(`\\b${escapeForRegex(qualifiedName)}\\b`, 'g')], - 200, - (_uri, text) => text.includes(qualifiedName) - ); - aliasMatches = await findGoImportAliasUsages(goPkg, symbolName, 200); - } - const bareMatches = await findGoUsagesInWorkspaceByRegexes( - [new RegExp(`(?> { - const exclude = '**/{node_modules,vendor,out,dist,.git}/**'; - const results: Array<{ uri: vscode.Uri; range: vscode.Range; preview: string }> = []; - const uris = await vscode.workspace.findFiles('**/*.go', exclude, 5000); - const typeRe = new RegExp(`(?:\\b|\\.)${escapeForRegex(messageName)}\\s*\\{`); - const fieldRe = new RegExp(`\\b${escapeForRegex(goFieldName)}\\s*:`); - - for (const uri of uris) { - const base = path.basename(uri.fsPath); - if (base.endsWith('.pb.go') || base.endsWith('.pb.gw.go') || base.includes('.pb.')) continue; - let text: string; - try { - text = fs.readFileSync(uri.fsPath, 'utf8'); - } catch { - continue; - } - - const lines = text.split('\n'); - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - if (!typeRe.test(line)) continue; - - let started = false; - let depth = 0; - for (let j = i; j < lines.length && j < i + 60; j += 1) { - const l = lines[j]; - if (!started) { - const m = typeRe.exec(l); - typeRe.lastIndex = 0; - if (m && m.index !== undefined) { - const braceIdx = l.indexOf('{', m.index); - if (braceIdx >= 0) { - started = true; - depth = 1; - const rest = l.slice(braceIdx + 1); - depth += countChar(rest, '{'); - depth -= countChar(rest, '}'); - } - } - } else { - depth += countChar(l, '{'); - depth -= countChar(l, '}'); - } - - if (fieldRe.test(l)) { - const idx = l.search(new RegExp(`\\b${escapeForRegex(goFieldName)}\\b`)); - const start = idx >= 0 ? idx : 0; - results.push({ - uri, - range: new vscode.Range(new vscode.Position(j, start), new vscode.Position(j, start + goFieldName.length)), - preview: l.replace(/\s+/g, ' ').trim() - }); - if (results.length >= 200) return results; - } - - if (started && depth <= 0 && j > i) break; - } - } - } - - return results; -} - -async function findGoVariableFieldUsages( - typeRef: string, - goFieldName: string -): Promise> { - const exclude = '**/{node_modules,vendor,out,dist,.git}/**'; - const results: Array<{ uri: vscode.Uri; range: vscode.Range; preview: string }> = []; - const uris = await vscode.workspace.findFiles('**/*.go', exclude, 5000); - - const typeRe = new RegExp(`${escapeForRegex(typeRef)}`); - // Regex to find variable assignments/declarations: `varName := &Type{`, `varName := new(Type)`, `var varName Type` - const assignRes = [ - { re: new RegExp(`\\b([A-Za-z_][A-Za-z0-9_]*)\\s*:=\\s*&?\\s*([A-Za-z0-9_\\.]+)`, 'g'), nameIdx: 1, typeIdx: 2 }, - { re: new RegExp(`\\bvar\\s+([A-Za-z_][A-Za-z0-9_]*)\\s+\\*?([A-Za-z0-9_\\.]+)`, 'g'), nameIdx: 1, typeIdx: 2 }, - { re: new RegExp(`func\\s*\\([^\\)]*?\\b([A-Za-z_][A-Za-z0-9_]*)\\s+\\*?([A-Za-z0-9_\\.]+)\\s*\\)`, 'g'), nameIdx: 1, typeIdx: 2 } - ]; - - const fieldRegexes = [ - new RegExp(`\\b([A-Za-z_][A-Za-z0-9_]*)\\.${escapeForRegex(goFieldName)}\\b`, 'g'), - new RegExp(`\\b([A-Za-z_][A-Za-z0-9_]*)\\.Get${escapeForRegex(goFieldName)}\\s*\\(`, 'g') - ]; - - for (const uri of uris) { - const base = path.basename(uri.fsPath); - if (base.endsWith('.pb.go') || base.endsWith('.pb.gw.go') || base.includes('.pb.')) continue; - let text: string; - try { - text = fs.readFileSync(uri.fsPath, 'utf8'); - } catch { - continue; - } - // Optimization: check if both type and field name exist in the file at all - if (!typeRe.test(text) || !text.includes(goFieldName)) continue; - - const lines = text.split('\n'); - // Map variable name -> target type ref string - const varToType = new Map(); - - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - - // 1. Update variable type tracking based on assignments in this line - for (const { re, nameIdx, typeIdx } of assignRes) { - re.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = re.exec(line)) !== null) { - const varName = m[nameIdx]; - const typeFound = m[typeIdx]; - if (varName && typeFound) { - // Normalize type: remove pointer and package prefix if present for simple comparison - // Or keep it full if typeRef is full. Here we use exact match or endsWith match. - if (typeFound === typeRef || typeRef.endsWith('.' + typeFound)) { - varToType.set(varName, typeRef); - } else { - // Redefined to a different type, remove from tracking - varToType.delete(varName); - } - } - if (m[0].length === 0) break; - } - } - - // 2. Check for field usages using tracked variables - for (const re of fieldRegexes) { - re.lastIndex = 0; - let m: RegExpExecArray | null; - while ((m = re.exec(line)) !== null) { - const varName = m[1]; - if (varName && varToType.get(varName) === typeRef) { - const hit = m[0] ?? ''; - const idx = hit.lastIndexOf(goFieldName); - const start = (m.index ?? 0) + (idx >= 0 ? idx : 0); - results.push({ - uri, - range: new vscode.Range( - new vscode.Position(i, start), - new vscode.Position(i, start + goFieldName.length) - ), - preview: line.replace(/\s+/g, ' ').trim() - }); - if (results.length >= 200) return results; - } - if (m[0].length === 0) break; - } - } - } - } - - return results; -} - -async function resolveProtoUri(protoPathFromPbGo: string): Promise { - if (path.isAbsolute(protoPathFromPbGo) && fs.existsSync(protoPathFromPbGo)) { - return vscode.Uri.file(protoPathFromPbGo); - } - - const { protoRoots, searchInWorkspace } = getConfig(); - - for (const root of protoRoots) { - const full = path.join(root, protoPathFromPbGo); - if (fs.existsSync(full)) { - return vscode.Uri.file(full); - } - } - - if (searchInWorkspace) { - const glob = `**/${escapeForGlob(protoPathFromPbGo)}`; - const matches = await vscode.workspace.findFiles(glob, '**/{node_modules,vendor,out,.git}/**', 5); - if (matches.length > 0) return matches[0]; - } - - return undefined; -} - -async function resolveProtoDefinition(document: vscode.TextDocument, position: vscode.Position): Promise { - const wordRange = document.getWordRangeAtPosition(position, /[A-Za-z_][A-Za-z0-9_]*/); - if (!wordRange) return undefined; - const symbolName = document.getText(wordRange); - if (!symbolName) return undefined; - - const defs = await vscode.commands.executeCommand( - 'vscode.executeDefinitionProvider', - document.uri, - position - ); - - if (!defs || defs.length === 0) return undefined; - - // Find the first definition in a .pb.go file - const pbGoDef = defs.find(d => { - const uri = 'targetUri' in d ? (d as vscode.LocationLink).targetUri : (d as vscode.Location).uri; - return uri.fsPath.endsWith('.pb.go') || uri.fsPath.endsWith('.pb.gw.go'); - }); - - if (!pbGoDef) return undefined; - - const pbGoUri = 'targetUri' in pbGoDef ? (pbGoDef as vscode.LocationLink).targetUri : (pbGoDef as vscode.Location).uri; - const pbGoRange = 'targetRange' in pbGoDef ? (pbGoDef as vscode.LocationLink).targetRange : (pbGoDef as vscode.Location).range; - const defPath = pbGoUri.fsPath; - - let pbGoText: string; - try { - pbGoText = fs.readFileSync(defPath, 'utf8'); - } catch { - return undefined; - } - - // Determine container message if it's a field - let containerName: string | undefined; - const pbGoLines = pbGoText.split('\n'); - const defLineIndex = pbGoRange.start.line; - const defLine = pbGoLines[defLineIndex]; - - // Check if it's a struct field: `FieldName type `protobuf:"..."`` - if (defLine.includes('`protobuf:')) { - // Scan backwards to find the struct name - for (let i = defLineIndex - 1; i >= 0 && i > defLineIndex - 100; i--) { - const line = pbGoLines[i]; - const structMatch = line.match(/^type\s+([A-Za-z_][A-Za-z0-9_]*)\s+struct\s*\{/); - if (structMatch) { - containerName = structMatch[1]; - break; - } - } - } - - const protoPathFromPbGo = extractProtoPathFromPbGo(pbGoText); - if (!protoPathFromPbGo) return undefined; - - const protoUri = await resolveProtoUri(protoPathFromPbGo); - if (!protoUri) return undefined; - - const protoDoc = await vscode.workspace.openTextDocument(protoUri); - const protoText = protoDoc.getText(); - - const match = findProtoSymbolMatch(protoText, symbolName, containerName); - const range = match - ? new vscode.Range(protoDoc.positionAt(match.startOffset), protoDoc.positionAt(match.endOffset)) - : new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)); - - return { protoUri, targetRange: range }; -} - -async function goToProtoDefinition(editor: vscode.TextEditor): Promise { - const document = editor.document; - const position = editor.selection.active; - const key = makeResolveKey(document.uri, position); - if (resolvingKeys.has(key)) return false; - resolvingKeys.add(key); - let resolved: ResolveResult | undefined; - try { - resolved = await resolveProtoDefinition(document, position); - } finally { - resolvingKeys.delete(key); - } - if (!resolved) return false; - - const targetDoc = await vscode.workspace.openTextDocument(resolved.protoUri); - await vscode.window.showTextDocument(targetDoc, { selection: resolved.targetRange, preserveFocus: false, preview: true }); - return true; -} - -async function getGoUsagesForProtoPosition( - doc: vscode.TextDocument, - pos: vscode.Position, - withProgress: boolean -): Promise { - const strings = getStrings(); - const fieldCtx = getProtoFieldContextAtPosition(doc, pos); - - if (fieldCtx?.kind === 'fieldName') { - const goField = toGoExportedName(fieldCtx.fieldName); - const messageName = fieldCtx.messageName; - const regexes = [ - new RegExp(`\\.${escapeForRegex(goField)}\\b`, 'g'), - new RegExp(`\\.Get${escapeForRegex(goField)}\\s*\\(`, 'g'), - new RegExp(`\\b${escapeForRegex(goField)}\\s*:`, 'g') - ]; - const findTask = async () => { - if (messageName && messageName.length > 0) { - const goPkg = await resolveGoPackageForProtoFile(doc); - const qualifiedType = goPkg ? `${goPkg}.${messageName}` : messageName; - const filePredicate = goPkg - ? (_uri: vscode.Uri, text: string) => text.includes(`${goPkg}.`) || text.includes(qualifiedType) - : undefined; - - const [varUsages, composite] = await Promise.all([ - findGoVariableFieldUsages(qualifiedType, goField), - findGoCompositeFieldUsages(qualifiedType, goField) - ]); - if (varUsages.length > 0) return [...varUsages, ...composite].slice(0, 200); - if (composite.length > 0) return composite.slice(0, 200); - - const narrowed = await findGoUsagesInWorkspaceByRegexes(regexes, 200, filePredicate); - if (narrowed.length > 0) return narrowed.slice(0, 200); - } - return findGoUsagesInWorkspaceByRegexes( - regexes, - 200, - messageName && messageName.length > 0 ? (_uri, text) => text.includes(messageName) : undefined - ); - }; - - const combined = withProgress - ? await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: strings.searchingGoUsages, cancellable: false }, findTask) - : await findTask(); - return combined.map(m => new vscode.Location(m.uri, m.range)); - } - - if (fieldCtx?.kind === 'fieldType') { - const findTask = () => findGoUsagesPreferQualifiedName(doc, fieldCtx.typeName); - const matches = withProgress - ? await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: strings.searchingGoUsages, cancellable: false }, findTask) - : await findTask(); - const goLocations = matches.map(m => new vscode.Location(m.uri, m.range)); - const protoLocations = findProtoSymbolLocationsInDocument(doc, fieldCtx.typeName); - return mergeLocations(protoLocations, goLocations); - } - - const name = getProtoDefinitionNameAtPosition(doc, pos); - if (name) { - const findTask = () => findGoUsagesPreferQualifiedName(doc, name); - const matches = withProgress - ? await vscode.window.withProgress({ location: vscode.ProgressLocation.Notification, title: strings.searchingGoUsages, cancellable: false }, findTask) - : await findTask(); - const goLocations = matches.map(m => new vscode.Location(m.uri, m.range)); - const protoLocations = findProtoSymbolLocationsInDocument(doc, name); - return mergeLocations(protoLocations, goLocations); - } - - return undefined; -} - -class ProtoGoDefinitionProvider implements vscode.DefinitionProvider { - async provideDefinition(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise { - const locations = await getGoUsagesForProtoPosition(document, position, false); - return locations || []; - } -} +import { activate as activateExtension } from './commands'; export function activate(context: vscode.ExtensionContext) { - const viewProvider = new ProtoJumpViewProvider(context.extensionUri); - const output = vscode.window.createOutputChannel('JumpProto'); - context.subscriptions.push(vscode.window.registerWebviewViewProvider('protoJump.view', viewProvider)); - context.subscriptions.push(output); - - context.subscriptions.push( - vscode.languages.registerDefinitionProvider( - [{ language: 'proto' }, { language: 'proto3' }, { language: 'protobuf' }], - new ProtoGoDefinitionProvider() - ) - ); - - const status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 100); - status.text = 'JumpProto'; - status.command = 'protoJump.goToProtoDefinition'; - context.subscriptions.push(status); - - const getUpdateTarget = (): vscode.ConfigurationTarget => - vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0 - ? vscode.ConfigurationTarget.Workspace - : vscode.ConfigurationTarget.Global; - - const updateStatusVisibility = () => { - const editor = vscode.window.activeTextEditor; - if (editor && editor.document.languageId === 'go') { - status.show(); - } else { - status.hide(); - } - }; - updateStatusVisibility(); - context.subscriptions.push(vscode.window.onDidChangeActiveTextEditor(() => updateStatusVisibility())); - - context.subscriptions.push( - vscode.commands.registerTextEditorCommand('protoJump.goToProtoDefinition', async editor => { - const strings = getStrings(); - const ok = await goToProtoDefinition(editor); - if (!ok) { - vscode.window.showInformationMessage(strings.resolveFailed); - } - }) - ); - - context.subscriptions.push( - vscode.languages.registerDefinitionProvider({ language: 'go' }, { - provideDefinition: async (document, position) => { - const key = makeResolveKey(document.uri, position); - if (resolvingKeys.has(key)) return undefined; - resolvingKeys.add(key); - let resolved: ResolveResult | undefined; - let nativeDefs: vscode.Location[] = []; - try { - const defs = await vscode.commands.executeCommand( - 'vscode.executeDefinitionProvider', - document.uri, - position - ); - nativeDefs = (defs ?? []) - .map(d => 'targetUri' in d - ? new vscode.Location((d as vscode.LocationLink).targetUri, (d as vscode.LocationLink).targetSelectionRange ?? (d as vscode.LocationLink).targetRange) - : (d as vscode.Location)) - .filter(loc => loc.uri.fsPath.endsWith('.pb.go') || loc.uri.fsPath.endsWith('.pb.gw.go')); - resolved = await resolveProtoDefinition(document, position); - } finally { - resolvingKeys.delete(key); - } - if (!resolved) return undefined; - const protoLocation = new vscode.Location(resolved.protoUri, resolved.targetRange); - const ordered: vscode.Location[] = [protoLocation]; - const seen = new Set([ - `${protoLocation.uri.toString()}#${protoLocation.range.start.line}:${protoLocation.range.start.character}-${protoLocation.range.end.line}:${protoLocation.range.end.character}` - ]); - for (const loc of nativeDefs) { - const key = `${loc.uri.toString()}#${loc.range.start.line}:${loc.range.start.character}-${loc.range.end.line}:${loc.range.end.character}`; - if (seen.has(key)) continue; - seen.add(key); - ordered.push(loc); - } - return ordered; - } - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.openSettings', async () => { - const strings = getStrings(); - await vscode.commands.executeCommand('workbench.action.openSettingsJson'); - vscode.window.showInformationMessage(strings.openSettingsOpenedJson); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.addProtoRoot', async () => { - const strings = getStrings(); - const picked = await vscode.window.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: true, - openLabel: strings.addProtoRoot - }); - if (!picked || picked.length === 0) return; - const config = vscode.workspace.getConfiguration('protoJump'); - const existing = (config.get('protoRoots') ?? []).filter(Boolean); - const next = Array.from(new Set([...existing, ...picked.map(u => u.fsPath)])); - await config.update('protoRoots', next, getUpdateTarget()); - viewProvider.refresh(); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.removeProtoRoot', async (arg?: unknown) => { - const rootPath = - typeof arg === 'string' - ? arg - : typeof arg === 'object' && arg && 'meta' in (arg as any) && (arg as any).meta?.kind === 'protoRoot' - ? (arg as any).meta.rootPath - : undefined; - if (!rootPath) return; - const config = vscode.workspace.getConfiguration('protoJump'); - const existing = (config.get('protoRoots') ?? []).filter(Boolean); - const next = existing.filter(p => p !== rootPath); - await config.update('protoRoots', next, getUpdateTarget()); - viewProvider.refresh(); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.toggleSearchInWorkspace', async () => { - const config = vscode.workspace.getConfiguration('protoJump'); - const current = config.get('searchInWorkspace') ?? true; - await config.update('searchInWorkspace', !current, getUpdateTarget()); - viewProvider.refresh(); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.refreshView', () => viewProvider.refresh()) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.selectLanguage', async () => { - const strings = getStrings(); - const current = getUiLanguage(); - const picked = await vscode.window.showQuickPick( - [ - { label: strings.languageChinese, value: 'zh' as const }, - { label: strings.languageEnglish, value: 'en' as const } - ], - { title: strings.languageSelectTitle } - ); - if (!picked) return; - if (picked.value === current) return; - const config = vscode.workspace.getConfiguration('protoJump'); - await config.update('uiLanguage', picked.value, vscode.ConfigurationTarget.Global); - viewProvider.refresh(); - vscode.window.showInformationMessage(getStrings(picked.value).languageUpdated); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.editMakeProtoRule', async () => { - const strings = getStrings(); - if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { - await vscode.commands.executeCommand('workbench.action.openWorkspaceSettingsFile'); - } else { - await vscode.commands.executeCommand('workbench.action.openSettingsJson', 'protoJump.makeProtoCommand'); - } - vscode.window.showInformationMessage(strings.makeProtoRuleOpenedJson); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.openMakeProtoRuleHelp', async () => { - const strings = getStrings(); - const placeholders = [ - { token: '{workspaceFolder}', desc: strings.makeProtoRuleHelpPlaceholderWorkspaceFolder }, - { token: '{protoSrcRoot}', desc: strings.makeProtoRuleHelpPlaceholderProtoSrcRoot }, - { token: '{protoFile}', desc: strings.makeProtoRuleHelpPlaceholderProtoFile }, - { token: '{protoFileNoExt}', desc: strings.makeProtoRuleHelpPlaceholderProtoFileNoExt }, - { token: '{protoDir}', desc: strings.makeProtoRuleHelpPlaceholderProtoDir }, - { token: '{relativeProto}', desc: strings.makeProtoRuleHelpPlaceholderRelativeProto }, - { token: '{relativeProtoNoExt}', desc: strings.makeProtoRuleHelpPlaceholderRelativeProtoNoExt }, - { token: '{protoPackage}', desc: strings.makeProtoRuleHelpPlaceholderProtoPackage } - ]; - - const panel = vscode.window.createWebviewPanel( - 'protoJump.makeProtoRuleHelp', - strings.makeProtoRuleHelpTitle, - vscode.ViewColumn.Active, - { enableScripts: false } - ); - - panel.webview.html = ` - - - - - - ${escapeHtml(strings.makeProtoRuleHelpTitle)} - - - -

${escapeHtml(strings.makeProtoRuleHelpTitle)}

-

${escapeHtml(strings.makeProtoRuleHelpIntro)}

-

${escapeHtml(strings.makeProtoRuleHelpQuickStartTitle)}

-
    -
  1. ${escapeHtml(strings.makeProtoRuleHelpQuickStartStep1)}
  2. -
  3. ${escapeHtml(strings.makeProtoRuleHelpQuickStartStep2)}
  4. -
  5. ${escapeHtml(strings.makeProtoRuleHelpQuickStartStep3)}
  6. -
-

${escapeHtml(strings.makeProtoRuleHelpUsageTitle)}

-

${escapeHtml(strings.makeProtoRuleHelpUsage)}

-

${escapeHtml(strings.makeProtoRuleHelpDemoTitle)}

-

${escapeHtml(strings.makeProtoRuleHelpDemoContext)}

-

${escapeHtml(strings.makeProtoRuleHelpDemoRuleLabel)}

-
cd {protoSrcRoot} && make special_proto packagename={protoPackage} filename={protoFileNoExt}
-

${escapeHtml(strings.makeProtoRuleHelpDemoResultLabel)}

-
cd /ABSOLUTE/PATH/TO/proto_src && make special_proto packagename=activity filename=user_profile
-

${escapeHtml(strings.makeProtoRuleHelpAdvancedDemoTitle)}

-

${escapeHtml(strings.makeProtoRuleHelpAdvancedDemoContext)}

-

${escapeHtml(strings.makeProtoRuleHelpAdvancedDemoRuleLabel)}

-
cd {protoSrcRoot} && case {relativeProto} in
-  rpc/*) make rpc pkg={protoFileNoExt} ;;
-  api/*) make api pkg={protoFileNoExt} ;;
-  model/*) make golang_model_proto ;;
-  *) make special_proto packagename={protoPackage} filename={protoFileNoExt} ;;
-esac
-

${escapeHtml(strings.makeProtoRuleHelpAdvancedDemoResultLabel)}

-
cd /ABSOLUTE/PATH/TO/proto_src && case rpc/user/get_user.proto in
-  rpc/*) make rpc pkg=get_user ;;
-  api/*) make api pkg=get_user ;;
-  model/*) make golang_model_proto ;;
-  *) make special_proto packagename=user filename=get_user ;;
-esac
-

${escapeHtml(strings.makeProtoRuleHelpPlaceholdersTitle)}

-
    - ${placeholders.map(item => `
  • ${escapeHtml(item.token)}: ${escapeHtml(item.desc)}
  • `).join('')} -
-

${escapeHtml(strings.makeProtoRuleHelpTipsTitle)}

-

${escapeHtml(strings.makeProtoRuleHelpTips)}

-

${escapeHtml(strings.makeProtoRuleHelpTroubleshootingTitle)}

-
    -
  • ${escapeHtml(strings.makeProtoRuleHelpTroubleshooting1)}
  • -
  • ${escapeHtml(strings.makeProtoRuleHelpTroubleshooting2)}
  • -
  • ${escapeHtml(strings.makeProtoRuleHelpTroubleshooting3)}
  • -
- -`; - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.setMakeProtoRule', async (value?: unknown) => { - const strings = getStrings(); - const config = vscode.workspace.getConfiguration('protoJump'); - await config.update('makeProtoCommand', typeof value === 'string' ? value.trim() : '', getUpdateTarget()); - viewProvider.refresh(); - vscode.window.showInformationMessage(strings.makeProtoRuleSaved); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.testMakeProtoRule', async (value?: unknown) => { - const strings = getStrings(); - const editor = vscode.window.activeTextEditor; - if (!editor || !editor.document.uri.fsPath.endsWith('.proto')) { - vscode.window.showInformationMessage(strings.testMakeProtoRuleNeedActiveProto); - return; - } - - const compileCtx = resolveProtoCompileContext(editor.document); - if (!compileCtx) { - vscode.window.showInformationMessage(strings.testMakeProtoRuleNeedActiveProto); - return; - } - - const rule = typeof value === 'string' ? value.trim() : ''; - if (!rule) { - vscode.window.showInformationMessage(strings.makeProtoRuleEmpty); - return; - } - - const rendered = applyMakeProtoTemplate(rule, compileCtx); - output.clear(); - output.appendLine(`[dry-run] ${rendered}`); - - try { - await execFile('/bin/zsh', ['-n', '-c', rendered], { - cwd: compileCtx.workspaceFolder, - maxBuffer: 10 * 1024 * 1024 - }); - vscode.window.showInformationMessage(strings.testMakeProtoRuleDone); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - output.appendLine(message); - output.show(true); - vscode.window.showErrorMessage(`${strings.testMakeProtoRuleFailed} ${message}`); - } - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.compileCurrentProto', async () => { - const strings = getStrings(); - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showInformationMessage(strings.compileCurrentProtoInvalid); - return; - } - - const compileCtx = resolveProtoCompileContext(editor.document); - if (!compileCtx) { - vscode.window.showInformationMessage(strings.compileCurrentProtoInvalid); - return; - } - - let { makeProtoCommand } = getConfig(); - if (!makeProtoCommand) { - await vscode.commands.executeCommand('protoJump.editMakeProtoRule'); - makeProtoCommand = getConfig().makeProtoCommand; - if (!makeProtoCommand) return; - } - - const rendered = applyMakeProtoTemplate(makeProtoCommand, compileCtx); - output.clear(); - output.appendLine(`[command] ${rendered}`); - - try { - await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: strings.compilingCurrentProto, cancellable: false }, - async () => { - const result = await execFile('/bin/zsh', ['-lc', rendered], { - cwd: compileCtx.workspaceFolder, - maxBuffer: 10 * 1024 * 1024 - }); - if (result.stdout) output.appendLine(result.stdout.trimEnd()); - if (result.stderr) output.appendLine(result.stderr.trimEnd()); - } - ); - vscode.window.showInformationMessage(strings.compileCurrentProtoDone); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - output.appendLine(message); - output.show(true); - vscode.window.showErrorMessage(`${strings.compileCurrentProtoFailed} ${message}`); - } - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('protoJump.goToGoUsage', async (arg?: unknown) => { - const strings = getStrings(); - const active = (isTextEditor(arg) ? arg : vscode.window.activeTextEditor) ?? undefined; - if (!active) { - vscode.window.showInformationMessage(strings.protoDefinitionRequired); - return; - } - - const locations = await getGoUsagesForProtoPosition(active.document, active.selection.active, true); - if (locations && locations.length > 0) { - await showReferencesNative(active.document.uri, active.selection.active, locations); - return; - } - - // fallback to pick list if nothing at cursor - let name = getProtoDefinitionNameAtCursor(active); - if (!name) { - name = await pickProtoDefinitionName(active, strings); - if (!name) { - vscode.window.showInformationMessage(strings.protoDefinitionRequired); - return; - } - const pos = findProtoDefinitionPosition(active.document, name); - if (pos) active.selection = new vscode.Selection(pos, pos); - } - - const progressTitle = strings.searchingGoUsages; - const matches = await vscode.window.withProgress( - { location: vscode.ProgressLocation.Notification, title: progressTitle, cancellable: false }, - () => findGoUsagesPreferQualifiedName(active.document, name!) - ); - - if (matches.length === 0) { - vscode.window.showInformationMessage(strings.noGoUsagesFound); - return; - } - - const matchLocs = matches.map(m => new vscode.Location(m.uri, m.range)); - await showReferencesNative(active.document.uri, active.selection.active, matchLocs); - }) - ); - - context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(e => { - if (e.affectsConfiguration('protoJump')) viewProvider.refresh(); - }) - ); + activateExtension(context); } export function deactivate() {} diff --git a/src/goText.ts b/src/goText.ts new file mode 100644 index 0000000..d396f48 --- /dev/null +++ b/src/goText.ts @@ -0,0 +1,590 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +export type GoPackageInfo = { + packageName: string; + importPath?: string; +}; + +export type GoTextUsageKind = + | 'qualified' + | 'alias' + | 'bare' + | 'compositeField' + | 'selectorField' + | 'getter'; + +export type GoTextUsage = { + line: number; + start: number; + end: number; + text: string; + kind: GoTextUsageKind; +}; + +export type GoSymbolSearchPlan = { + symbolName: string; + qualifiedName?: string; + aliases: string[]; + includeBare: boolean; +}; + +type GoToken = { + type: 'identifier' | 'string' | 'number' | 'punctuation' | 'operator'; + value: string; + startOffset: number; + endOffset: number; +}; + +type TokenContext = { + tokens: GoToken[]; + lineStarts: number[]; + lines: string[]; +}; + +export function parseGoPackageInfo(protoText: string): GoPackageInfo | undefined { + const goPackageMatch = protoText.match(/^\s*option\s+go_package\s*=\s*"([^"]+)";/m); + if (!goPackageMatch) return undefined; + + const value = goPackageMatch[1].trim(); + if (!value) return undefined; + if (value.includes(';')) { + const [importPath, packageName] = value.split(';'); + const trimmedPackageName = packageName?.trim(); + if (!trimmedPackageName) return undefined; + return { + packageName: trimmedPackageName, + importPath: importPath.trim() || undefined + }; + } + + const parts = value.split('/'); + const packageName = parts[parts.length - 1]?.trim(); + if (!packageName) return undefined; + return { + packageName, + importPath: value.includes('/') ? value : undefined + }; +} + +export function buildGoSymbolSearchPlan( + goText: string, + symbolName: string, + goPkg?: GoPackageInfo +): GoSymbolSearchPlan { + return { + symbolName, + qualifiedName: goPkg ? `${goPkg.packageName}.${symbolName}` : undefined, + aliases: goPkg?.importPath ? findImportAliases(goText, goPkg.importPath, goPkg.packageName) : [], + includeBare: true + }; +} + +export function findImportAliases(goText: string, importPath: string, packageName: string): string[] { + const { tokens } = tokenizeGo(goText); + const aliases = new Set(); + + for (let i = 0; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token.type !== 'identifier' || token.value !== 'import') continue; + const next = tokens[i + 1]; + if (!next) continue; + + if (next.type === 'string') { + addImportAlias(aliases, undefined, next.value, importPath, packageName); + continue; + } + + if ((next.type === 'identifier' || next.value === '.' || next.value === '_') && tokens[i + 2]?.type === 'string') { + addImportAlias(aliases, next.value, tokens[i + 2].value, importPath, packageName); + continue; + } + + if (next.value === '(') { + for (let j = i + 2; j < tokens.length && tokens[j].value !== ')'; j += 1) { + const current = tokens[j]; + if (current.type === 'string') { + addImportAlias(aliases, undefined, current.value, importPath, packageName); + continue; + } + if ((current.type === 'identifier' || current.value === '.' || current.value === '_') && tokens[j + 1]?.type === 'string') { + addImportAlias(aliases, current.value, tokens[j + 1].value, importPath, packageName); + j += 1; + } + } + } + } + + return Array.from(aliases); +} + +export function findGoSymbolUsagesInText( + goText: string, + symbolName: string, + goPkg?: GoPackageInfo +): GoTextUsage[] { + const ctx = tokenizeGo(goText); + const plan = buildGoSymbolSearchPlan(goText, symbolName, goPkg); + const exactMatches = plan.qualifiedName + ? findQualifiedSymbolUsages(ctx, goPkg!.packageName, symbolName, 'qualified') + : []; + const aliasMatches = plan.aliases + .filter(alias => alias !== goPkg?.packageName) + .flatMap(alias => findQualifiedSymbolUsages(ctx, alias, symbolName, 'alias')); + const bareMatches = plan.includeBare ? findBareSymbolUsages(ctx, symbolName) : []; + return mergeTextUsages(exactMatches, aliasMatches, bareMatches); +} + +export function findGoCompositeFieldUsagesInText( + goText: string, + messageName: string, + goFieldName: string, + goPkg?: GoPackageInfo +): GoTextUsage[] { + const ctx = tokenizeGo(goText); + const typeRefs = buildTypeRefs(goText, messageName, goPkg); + const results: GoTextUsage[] = []; + + for (let i = 0; i < ctx.tokens.length; i += 1) { + const match = matchTypeRefAt(ctx.tokens, i, typeRefs); + if (!match || ctx.tokens[match.nextIndex]?.value !== '{') continue; + const closeBraceIndex = findMatchingBraceTokenIndex(ctx.tokens, match.nextIndex); + if (closeBraceIndex === undefined) continue; + + let depth = 0; + for (let j = match.nextIndex; j < closeBraceIndex; j += 1) { + const token = ctx.tokens[j]; + if (token.value === '{') { + depth += 1; + continue; + } + if (token.value === '}') { + depth -= 1; + continue; + } + if (depth !== 1) continue; + if (token.type !== 'identifier' || token.value !== goFieldName) continue; + if (ctx.tokens[j + 1]?.value !== ':') continue; + results.push(toUsage(ctx, token.startOffset, token.endOffset, 'compositeField')); + } + } + + return mergeTextUsages(results); +} + +export function findGoVariableFieldUsagesInText( + goText: string, + messageName: string, + goFieldName: string, + goPkg?: GoPackageInfo +): GoTextUsage[] { + const ctx = tokenizeGo(goText); + const typeRefs = buildTypeRefs(goText, messageName, goPkg); + const varToType = new Set(); + const results: GoTextUsage[] = []; + + for (let i = 0; i < ctx.tokens.length; i += 1) { + trackVariableType(ctx.tokens, i, typeRefs, varToType); + + const token = ctx.tokens[i]; + const dot = ctx.tokens[i + 1]; + const selector = ctx.tokens[i + 2]; + if (token.type !== 'identifier' || dot?.value !== '.' || selector?.type !== 'identifier') continue; + if (!varToType.has(token.value)) continue; + + if (selector.value === goFieldName) { + results.push(toUsage(ctx, selector.startOffset, selector.endOffset, 'selectorField')); + continue; + } + if (selector.value === `Get${goFieldName}` && ctx.tokens[i + 3]?.value === '(') { + const fieldStart = selector.startOffset + 'Get'.length; + results.push(toUsage(ctx, fieldStart, selector.endOffset, 'getter')); + } + } + + return mergeTextUsages(results); +} + +export function findGoFieldAccessUsagesInText(goText: string, goFieldName: string): GoTextUsage[] { + const ctx = tokenizeGo(goText); + const results: GoTextUsage[] = []; + + for (let i = 0; i < ctx.tokens.length; i += 1) { + const token = ctx.tokens[i]; + if (token.type !== 'identifier') continue; + + if (token.value === goFieldName) { + const prev = ctx.tokens[i - 1]; + const next = ctx.tokens[i + 1]; + if (prev?.value === '.' || next?.value === ':') { + results.push(toUsage(ctx, token.startOffset, token.endOffset, prev?.value === '.' ? 'selectorField' : 'compositeField')); + } + continue; + } + + if (token.value === `Get${goFieldName}` && ctx.tokens[i - 1]?.value === '.' && ctx.tokens[i + 1]?.value === '(') { + const fieldStart = token.startOffset + 'Get'.length; + results.push(toUsage(ctx, fieldStart, token.endOffset, 'getter')); + } + } + + return mergeTextUsages(results); +} + +function addImportAlias( + aliases: Set, + rawAlias: string | undefined, + rawPath: string | undefined, + importPath: string, + packageName: string +) { + if (rawPath !== importPath) return; + const alias = rawAlias?.trim(); + if (!alias) { + aliases.add(packageName); + return; + } + if (alias === '_' || alias === '.') return; + aliases.add(alias); +} + +function findQualifiedSymbolUsages( + ctx: TokenContext, + qualifier: string, + symbolName: string, + kind: GoTextUsageKind +): GoTextUsage[] { + const results: GoTextUsage[] = []; + for (let i = 0; i < ctx.tokens.length - 2; i += 1) { + const qualifierToken = ctx.tokens[i]; + const dot = ctx.tokens[i + 1]; + const symbolToken = ctx.tokens[i + 2]; + if (qualifierToken.type !== 'identifier' || qualifierToken.value !== qualifier) continue; + if (dot.value !== '.') continue; + if (symbolToken.type !== 'identifier' || symbolToken.value !== symbolName) continue; + results.push(toUsage(ctx, qualifierToken.startOffset, symbolToken.endOffset, kind)); + } + return results; +} + +function findBareSymbolUsages(ctx: TokenContext, symbolName: string): GoTextUsage[] { + const results: GoTextUsage[] = []; + for (let i = 0; i < ctx.tokens.length; i += 1) { + const token = ctx.tokens[i]; + if (token.type !== 'identifier' || token.value !== symbolName) continue; + if (ctx.tokens[i - 1]?.value === '.') continue; + results.push(toUsage(ctx, token.startOffset, token.endOffset, 'bare')); + } + return results; +} + +function trackVariableType( + tokens: GoToken[], + index: number, + typeRefs: string[], + varToType: Set +) { + const token = tokens[index]; + if (!token) return; + + if (token.type === 'identifier' && tokens[index + 1]?.value === ':=') { + let typeIndex = index + 2; + if (tokens[typeIndex]?.value === '&') typeIndex += 1; + const typeMatch = matchTypeRefAt(tokens, typeIndex, typeRefs); + if (typeMatch) varToType.add(token.value); + return; + } + + if (token.type === 'identifier' && token.value === 'var' && tokens[index + 1]?.type === 'identifier') { + let typeIndex = index + 2; + if (tokens[typeIndex]?.value === '*') typeIndex += 1; + const typeMatch = matchTypeRefAt(tokens, typeIndex, typeRefs); + if (typeMatch) varToType.add(tokens[index + 1].value); + return; + } + + if (token.type === 'identifier' && token.value === 'func' && tokens[index + 1]?.value === '(') { + const receiverName = tokens[index + 2]; + let typeIndex = index + 3; + if (tokens[typeIndex]?.value === '*') typeIndex += 1; + const typeMatch = matchTypeRefAt(tokens, typeIndex, typeRefs); + if (receiverName?.type === 'identifier' && typeMatch && tokens[typeMatch.nextIndex]?.value === ')') { + varToType.add(receiverName.value); + } + trackFunctionParameterTypes(tokens, index, typeRefs, varToType); + return; + } + + if (token.type === 'identifier' && token.value === 'func') { + trackFunctionParameterTypes(tokens, index, typeRefs, varToType); + } +} + +function trackFunctionParameterTypes( + tokens: GoToken[], + funcIndex: number, + typeRefs: string[], + varToType: Set +) { + const bodyIndex = findNextTokenIndex(tokens, funcIndex + 1, '{'); + if (bodyIndex === undefined) return; + + for (let i = funcIndex + 1; i < bodyIndex; i += 1) { + if (tokens[i].value !== '(') continue; + const closeIndex = findMatchingParenTokenIndex(tokens, i); + if (closeIndex === undefined || closeIndex > bodyIndex) continue; + trackTypedNamesInParenGroup(tokens, i + 1, closeIndex, typeRefs, varToType); + i = closeIndex; + } +} + +function trackTypedNamesInParenGroup( + tokens: GoToken[], + startIndex: number, + endIndex: number, + typeRefs: string[], + varToType: Set +) { + for (let i = startIndex; i < endIndex; i += 1) { + const token = tokens[i]; + if (token.type !== 'identifier') continue; + + const directTypeMatch = matchTypeRefAfterPointers(tokens, i + 1, typeRefs); + if (directTypeMatch) { + varToType.add(token.value); + i = directTypeMatch.nextIndex - 1; + continue; + } + + const names = [token.value]; + let j = i; + while (tokens[j + 1]?.value === ',' && tokens[j + 2]?.type === 'identifier') { + j += 2; + names.push(tokens[j].value); + } + if (j === i) continue; + + const sharedTypeMatch = matchTypeRefAfterPointers(tokens, j + 1, typeRefs); + if (!sharedTypeMatch) continue; + for (const name of names) varToType.add(name); + i = sharedTypeMatch.nextIndex - 1; + } +} + +function matchTypeRefAfterPointers( + tokens: GoToken[], + index: number, + typeRefs: string[] +): { ref: string; nextIndex: number } | undefined { + let typeIndex = index; + while (tokens[typeIndex]?.value === '*') typeIndex += 1; + return matchTypeRefAt(tokens, typeIndex, typeRefs); +} + +function buildTypeRefs(goText: string, messageName: string, goPkg?: GoPackageInfo): string[] { + const refs = new Set([messageName]); + if (goPkg) refs.add(`${goPkg.packageName}.${messageName}`); + if (goPkg?.importPath) { + for (const alias of findImportAliases(goText, goPkg.importPath, goPkg.packageName)) { + refs.add(`${alias}.${messageName}`); + } + } + return Array.from(refs); +} + +function matchTypeRefAt( + tokens: GoToken[], + index: number, + typeRefs: string[] +): { ref: string; nextIndex: number } | undefined { + for (const ref of typeRefs) { + const parts = ref.split('.'); + if (parts.length === 1) { + if (tokens[index]?.type === 'identifier' && tokens[index].value === parts[0]) { + return { ref, nextIndex: index + 1 }; + } + continue; + } + if ( + tokens[index]?.type === 'identifier' && + tokens[index].value === parts[0] && + tokens[index + 1]?.value === '.' && + tokens[index + 2]?.type === 'identifier' && + tokens[index + 2].value === parts[1] + ) { + return { ref, nextIndex: index + 3 }; + } + } + return undefined; +} + +function tokenizeGo(goText: string): TokenContext { + const tokens: GoToken[] = []; + const lineStarts = buildLineStarts(goText); + const lines = goText.split('\n'); + let i = 0; + + while (i < goText.length) { + const ch = goText[i]; + const next = goText[i + 1]; + + if (ch === '/' && next === '/') { + i += 2; + while (i < goText.length && goText[i] !== '\n') i += 1; + continue; + } + if (ch === '/' && next === '*') { + i += 2; + while (i < goText.length && !(goText[i] === '*' && goText[i + 1] === '/')) i += 1; + i = Math.min(i + 2, goText.length); + continue; + } + if (ch === '"' || ch === '`' || ch === "'") { + const parsed = readQuotedString(goText, i); + tokens.push({ type: 'string', value: parsed.value, startOffset: i, endOffset: parsed.endOffset }); + i = parsed.endOffset; + continue; + } + if (isIdentifierStart(ch)) { + const start = i; + i += 1; + while (i < goText.length && isIdentifierPart(goText[i])) i += 1; + tokens.push({ type: 'identifier', value: goText.slice(start, i), startOffset: start, endOffset: i }); + continue; + } + if (isDigit(ch)) { + const start = i; + i += 1; + while (i < goText.length && isDigit(goText[i])) i += 1; + tokens.push({ type: 'number', value: goText.slice(start, i), startOffset: start, endOffset: i }); + continue; + } + if (ch === ':' && next === '=') { + tokens.push({ type: 'operator', value: ':=', startOffset: i, endOffset: i + 2 }); + i += 2; + continue; + } + if ('{}=;()<>[],.:*&'.includes(ch)) { + tokens.push({ type: 'punctuation', value: ch, startOffset: i, endOffset: i + 1 }); + } + i += 1; + } + + return { tokens, lineStarts, lines }; +} + +function readQuotedString(text: string, quoteOffset: number): { value: string; endOffset: number } { + const quote = text[quoteOffset]; + let i = quoteOffset + 1; + let value = ''; + while (i < text.length) { + if (quote !== '`' && text[i] === '\\') { + if (i + 1 < text.length) value += text[i + 1]; + i += 2; + continue; + } + if (text[i] === quote) return { value, endOffset: i + 1 }; + value += text[i]; + i += 1; + } + return { value, endOffset: i }; +} + +function findMatchingBraceTokenIndex(tokens: GoToken[], openBraceIndex: number): number | undefined { + let depth = 0; + for (let i = openBraceIndex; i < tokens.length; i += 1) { + const value = tokens[i].value; + if (value === '{') depth += 1; + if (value === '}') { + depth -= 1; + if (depth === 0) return i; + } + } + return undefined; +} + +function findMatchingParenTokenIndex(tokens: GoToken[], openParenIndex: number): number | undefined { + let depth = 0; + for (let i = openParenIndex; i < tokens.length; i += 1) { + const value = tokens[i].value; + if (value === '(') depth += 1; + if (value === ')') { + depth -= 1; + if (depth === 0) return i; + } + } + return undefined; +} + +function findNextTokenIndex(tokens: GoToken[], startIndex: number, value: string): number | undefined { + for (let i = startIndex; i < tokens.length; i += 1) { + if (tokens[i].value === value) return i; + } + return undefined; +} + +function toUsage( + ctx: TokenContext, + startOffset: number, + endOffset: number, + kind: GoTextUsageKind +): GoTextUsage { + const line = offsetToLine(ctx.lineStarts, startOffset); + const start = startOffset - ctx.lineStarts[line]; + const end = endOffset - ctx.lineStarts[line]; + return { + line, + start, + end, + text: ctx.lines[line]?.replace(/\s+/g, ' ').trim() ?? '', + kind + }; +} + +function mergeTextUsages(...groups: GoTextUsage[][]): GoTextUsage[] { + const out: GoTextUsage[] = []; + const seen = new Set(); + for (const group of groups) { + for (const usage of group) { + const key = `${usage.line}:${usage.start}-${usage.end}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(usage); + } + } + return out; +} + +function buildLineStarts(text: string): number[] { + const starts = [0]; + for (let i = 0; i < text.length; i += 1) { + if (text[i] === '\n') starts.push(i + 1); + } + return starts; +} + +function offsetToLine(lineStarts: number[], offset: number): number { + let low = 0; + let high = lineStarts.length - 1; + while (low <= high) { + const mid = Math.floor((low + high) / 2); + const start = lineStarts[mid]; + const next = lineStarts[mid + 1] ?? Number.POSITIVE_INFINITY; + if (offset >= start && offset < next) return mid; + if (offset < start) high = mid - 1; + else low = mid + 1; + } + return Math.max(0, lineStarts.length - 1); +} + +function isIdentifierStart(ch: string | undefined): boolean { + return !!ch && /[A-Za-z_]/.test(ch); +} + +function isIdentifierPart(ch: string | undefined): boolean { + return !!ch && /[A-Za-z0-9_]/.test(ch); +} + +function isDigit(ch: string | undefined): boolean { + return !!ch && /[0-9]/.test(ch); +} diff --git a/src/goUsage.ts b/src/goUsage.ts new file mode 100644 index 0000000..54967ba --- /dev/null +++ b/src/goUsage.ts @@ -0,0 +1,564 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import { getConfig, getWorkspaceExcludeGlob, type ProtoJumpConfig } from './config'; +import { + findGoCompositeFieldUsagesInText, + findGoFieldAccessUsagesInText, + findGoSymbolUsagesInText, + findGoVariableFieldUsagesInText, + parseGoPackageInfo, + type GoPackageInfo, + type GoTextUsage +} from './goText'; +import { getStrings } from './i18n'; +import { + findProtoDeclarationAtOffset, + findProtoDeclarationSymbol, + findProtoFieldContextAtOffset, + scanProtoSymbols, + type ProtoFieldContext +} from './protoScanner'; +import { mergeLocations, normalizeSlashes } from './utils'; + +export type GoUsage = { + uri: vscode.Uri; + range: vscode.Range; + preview: string; +}; + +const MAX_WORKSPACE_GO_FILES = 5000; +const MAX_USAGE_RESULTS = 200; +const MAX_REFERENCE_LOCATIONS = 200; +const PB_HEADER_BYTES = 6000; + +type CachedFileText = { + mtimeMs: number; + size: number; + text: string; +}; + +type CachedPbHeader = { + mtimeMs: number; + size: number; + source?: string; + packageName?: string; +}; + +let goFileUrisCache: { excludeKey: string; uris: vscode.Uri[] } | undefined; +let pbGoFileUrisCache: { excludeKey: string; uris: vscode.Uri[] } | undefined; +const fileTextCache = new Map(); +const pbHeaderCache = new Map(); +const protoPackageInfoCache = new Map(); + +function isCancellationRequested(token?: vscode.CancellationToken): boolean { + return token?.isCancellationRequested === true; +} + +function buildConfigCacheKey(config: ProtoJumpConfig): string { + return JSON.stringify({ + protoRoots: config.protoRoots, + exclude: config.exclude + }); +} + +function isGeneratedGoFile(uri: vscode.Uri): boolean { + const base = path.basename(uri.fsPath); + return base.endsWith('.pb.go') || base.endsWith('.pb.gw.go') || base.includes('.pb.'); +} + +async function getWorkspaceGoFileUris(token?: vscode.CancellationToken): Promise { + if (isCancellationRequested(token)) return []; + + const config = getConfig(); + const excludeKey = config.exclude.join('\n'); + if (goFileUrisCache?.excludeKey === excludeKey) return goFileUrisCache.uris; + + const uris = await vscode.workspace.findFiles( + '**/*.go', + getWorkspaceExcludeGlob(config), + MAX_WORKSPACE_GO_FILES, + token + ); + goFileUrisCache = { excludeKey, uris }; + return uris; +} + +async function getWorkspacePbGoFileUris(token?: vscode.CancellationToken): Promise { + if (isCancellationRequested(token)) return []; + + const config = getConfig(); + const excludeKey = config.exclude.join('\n'); + if (pbGoFileUrisCache?.excludeKey === excludeKey) return pbGoFileUrisCache.uris; + + const uris = await vscode.workspace.findFiles( + '**/*.pb.go', + getWorkspaceExcludeGlob(config), + MAX_WORKSPACE_GO_FILES, + token + ); + pbGoFileUrisCache = { excludeKey, uris }; + return uris; +} + +async function getNonGeneratedGoFileUris(token?: vscode.CancellationToken): Promise { + return (await getWorkspaceGoFileUris(token)).filter(uri => !isGeneratedGoFile(uri)); +} + +async function readCachedFileText(uri: vscode.Uri, token?: vscode.CancellationToken): Promise { + if (isCancellationRequested(token)) return undefined; + + let stat; + try { + stat = await fs.stat(uri.fsPath); + } catch { + return undefined; + } + + const cached = fileTextCache.get(uri.fsPath); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached.text; + } + + if (isCancellationRequested(token)) return undefined; + let text: string; + try { + text = await fs.readFile(uri.fsPath, 'utf8'); + } catch { + return undefined; + } + + fileTextCache.set(uri.fsPath, { + mtimeMs: stat.mtimeMs, + size: stat.size, + text + }); + return text; +} + +async function readCachedPbHeader(uri: vscode.Uri, token?: vscode.CancellationToken): Promise { + if (isCancellationRequested(token)) return undefined; + + let stat; + try { + stat = await fs.stat(uri.fsPath); + } catch { + return undefined; + } + + const cached = pbHeaderCache.get(uri.fsPath); + if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return cached; + } + + if (isCancellationRequested(token)) return undefined; + let handle: fs.FileHandle | undefined; + try { + handle = await fs.open(uri.fsPath, 'r'); + const buffer = Buffer.alloc(Math.min(PB_HEADER_BYTES, stat.size)); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const header = buffer.toString('utf8', 0, bytesRead); + const sourceMatch = header.match(/^\/\/\s*source:\s*(.+)\s*$/m); + const pkgMatch = header.match(/^package\s+([A-Za-z_][A-Za-z0-9_]*)/m); + const parsed = { + mtimeMs: stat.mtimeMs, + size: stat.size, + source: sourceMatch ? normalizeSlashes(sourceMatch[1].trim()) : undefined, + packageName: pkgMatch?.[1] + }; + pbHeaderCache.set(uri.fsPath, parsed); + return parsed; + } catch { + return undefined; + } finally { + await handle?.close(); + } +} + +export function clearGoUsageCaches(): void { + goFileUrisCache = undefined; + pbGoFileUrisCache = undefined; + fileTextCache.clear(); + pbHeaderCache.clear(); + protoPackageInfoCache.clear(); +} + +export function registerGoUsageCacheInvalidation(context: vscode.ExtensionContext): void { + const clear = () => clearGoUsageCaches(); + for (const pattern of ['**/*.go', '**/*.proto']) { + const watcher = vscode.workspace.createFileSystemWatcher(pattern); + context.subscriptions.push( + watcher, + watcher.onDidCreate(clear), + watcher.onDidChange(clear), + watcher.onDidDelete(clear) + ); + } +} + +function buildProtoSourceCandidates(protoFsPath: string, config: ProtoJumpConfig): string[] { + const { protoRoots } = config; + const candidates = new Set(); + candidates.add(normalizeSlashes(vscode.workspace.asRelativePath(protoFsPath, false))); + for (const root of protoRoots) { + const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; + if (protoFsPath.startsWith(rootWithSep)) { + candidates.add(normalizeSlashes(path.relative(root, protoFsPath))); + } + } + return Array.from(candidates).filter(Boolean); +} + +async function resolveGoPackageInfoForProtoFile( + protoDoc: vscode.TextDocument, + token?: vscode.CancellationToken +): Promise { + if (isCancellationRequested(token)) return undefined; + + const config = getConfig(); + const cacheKey = `${protoDoc.uri.toString()}#${protoDoc.version}#${buildConfigCacheKey(config)}`; + if (protoPackageInfoCache.has(cacheKey)) return protoPackageInfoCache.get(cacheKey); + + const strictCandidates = buildProtoSourceCandidates(protoDoc.uri.fsPath, config); + const basenameCandidate = normalizeSlashes(path.basename(protoDoc.uri.fsPath)); + const pbGos = await getWorkspacePbGoFileUris(token); + const basenameMatchedPackages: string[] = []; + const declaredInfo = parseGoPackageInfo(protoDoc.getText()); + + for (const uri of pbGos) { + if (isCancellationRequested(token)) return undefined; + const header = await readCachedPbHeader(uri, token); + if (!header?.source) continue; + const source = header.source; + if (!strictCandidates.includes(source)) { + if (source === basenameCandidate) { + if (header.packageName) basenameMatchedPackages.push(header.packageName); + } + continue; + } + + if (header.packageName) { + const result = { packageName: header.packageName, importPath: declaredInfo?.importPath }; + protoPackageInfoCache.set(cacheKey, result); + return result; + } + } + + if (basenameMatchedPackages.length === 1) { + const result = { packageName: basenameMatchedPackages[0], importPath: declaredInfo?.importPath }; + protoPackageInfoCache.set(cacheKey, result); + return result; + } + + if (declaredInfo) { + protoPackageInfoCache.set(cacheKey, declaredInfo); + return declaredInfo; + } + + const protoPackageMatch = protoDoc.getText().match(/^\s*package\s+([A-Za-z_][A-Za-z0-9_.]*)\s*;/m); + if (protoPackageMatch) { + const seg = protoPackageMatch[1].split('.').pop()?.trim(); + if (seg) { + const result = { packageName: seg }; + protoPackageInfoCache.set(cacheKey, result); + return result; + } + } + + protoPackageInfoCache.set(cacheKey, undefined); + return undefined; +} + +function getProtoDefinitionNameAtPosition(doc: vscode.TextDocument, pos: vscode.Position): string | undefined { + return getProtoDefinitionSymbolAtPosition(doc, pos)?.name; +} + +function getProtoDefinitionSymbolAtPosition(doc: vscode.TextDocument, pos: vscode.Position) { + if (!doc.uri.fsPath.endsWith('.proto')) return undefined; + return findProtoDeclarationAtOffset(doc.getText(), doc.offsetAt(pos)); +} + +export function getProtoDefinitionNameAtCursor(editor: vscode.TextEditor): string | undefined { + return getProtoDefinitionNameAtPosition(editor.document, editor.selection.active); +} + +function toGoExportedName(protoName: string): string { + const parts = protoName.split('_').filter(Boolean); + let out = ''; + for (let i = 0; i < parts.length; i += 1) { + const seg = parts[i]; + const mapped = seg.length === 0 ? seg : seg[0].toUpperCase() + seg.slice(1); + if (i > 0 && /^\d/.test(seg)) out += '_'; + out += mapped; + } + return out; +} + +function getProtoFieldContextAtPosition( + doc: vscode.TextDocument, + pos: vscode.Position +): ProtoFieldContext | undefined { + if (!doc.uri.fsPath.endsWith('.proto')) return undefined; + return findProtoFieldContextAtOffset(doc.getText(), doc.offsetAt(pos)); +} + +export async function pickProtoDefinitionName(editor: vscode.TextEditor, strings: ReturnType): Promise { + const doc = editor.document; + if (!doc.uri.fsPath.endsWith('.proto')) return undefined; + + const candidates = scanProtoSymbols(doc.getText()) + .filter(symbol => symbol.kind !== 'field') + .map(symbol => ({ + kind: symbol.kind, + name: symbol.name, + line: doc.positionAt(symbol.startOffset).line + })); + + const seen = new Set(); + const items = candidates + .filter(c => { + if (seen.has(c.name)) return false; + seen.add(c.name); + return true; + }) + .slice(0, 500) + .map(c => ({ + label: c.name, + description: c.kind, + line: c.line + })); + + const picked = await vscode.window.showQuickPick(items, { + title: strings.pickProtoDefinitionTitle, + placeHolder: strings.pickProtoDefinitionPlaceholder, + matchOnDescription: true + }); + if (!picked) return undefined; + return picked.label; +} + +export function findProtoDefinitionPosition(doc: vscode.TextDocument, name: string): vscode.Position | undefined { + const match = findProtoDeclarationSymbol(doc.getText(), name); + return match ? doc.positionAt(match.startOffset) : undefined; +} + +function findProtoSymbolLocationsInDocument( + doc: vscode.TextDocument, + symbolName: string +): vscode.Location[] { + const locations: vscode.Location[] = []; + const addLocation = (startOffset: number, endOffset: number) => { + locations.push(new vscode.Location(doc.uri, new vscode.Range(doc.positionAt(startOffset), doc.positionAt(endOffset)))); + }; + + for (const symbol of scanProtoSymbols(doc.getText())) { + if (symbol.name === symbolName) addLocation(symbol.startOffset, symbol.endOffset); + if (symbol.typeName?.name === symbolName) addLocation(symbol.typeName.startOffset, symbol.typeName.endOffset); + } + return locations; +} + +export async function showReferencesNative( + sourceUri: vscode.Uri, + sourcePos: vscode.Position, + locations: vscode.Location[] +): Promise { + await vscode.commands.executeCommand('editor.action.showReferences', sourceUri, sourcePos, locations); +} + +function mergeGoUsageResults(...groups: GoUsage[][]): GoUsage[] { + const out: GoUsage[] = []; + const seen = new Set(); + for (const group of groups) { + for (const usage of group) { + const key = `${usage.uri.toString()}#${usage.range.start.line}:${usage.range.start.character}-${usage.range.end.line}:${usage.range.end.character}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(usage); + } + } + return out; +} + +function toGoUsage(uri: vscode.Uri, usage: GoTextUsage): GoUsage { + return { + uri, + range: new vscode.Range( + new vscode.Position(usage.line, usage.start), + new vscode.Position(usage.line, usage.end) + ), + preview: usage.text + }; +} + +async function findGoUsagesInWorkspaceByText( + searchText: (text: string) => GoTextUsage[], + maxResults: number, + filePredicate?: (uri: vscode.Uri, text: string) => boolean, + token?: vscode.CancellationToken +): Promise { + const results: GoUsage[] = []; + const uris = await getNonGeneratedGoFileUris(token); + + for (const uri of uris) { + if (isCancellationRequested(token)) return results; + const text = await readCachedFileText(uri, token); + if (text === undefined) continue; + if (filePredicate && !filePredicate(uri, text)) continue; + const matches = searchText(text); + for (const match of matches) { + results.push(toGoUsage(uri, match)); + if (results.length >= maxResults) return results; + } + } + + return results; +} + +export async function findGoUsagesPreferQualifiedName( + protoDoc: vscode.TextDocument, + symbolName: string, + token?: vscode.CancellationToken +): Promise { + const goPkg = await resolveGoPackageInfoForProtoFile(protoDoc, token); + return findGoUsagesInWorkspaceByText( + text => findGoSymbolUsagesInText(text, symbolName, goPkg), + MAX_USAGE_RESULTS, + (_uri, text) => text.includes(symbolName), + token + ); +} + +async function findGoCompositeFieldUsages( + messageName: string, + goFieldName: string, + goPkg?: GoPackageInfo, + token?: vscode.CancellationToken +): Promise { + return findGoUsagesInWorkspaceByText( + text => findGoCompositeFieldUsagesInText(text, messageName, goFieldName, goPkg), + MAX_USAGE_RESULTS, + (_uri, text) => text.includes(goFieldName) && text.includes(messageName), + token + ); +} + +async function findGoVariableFieldUsages( + messageName: string, + goFieldName: string, + goPkg?: GoPackageInfo, + token?: vscode.CancellationToken +): Promise { + return findGoUsagesInWorkspaceByText( + text => findGoVariableFieldUsagesInText(text, messageName, goFieldName, goPkg), + MAX_USAGE_RESULTS, + (_uri, text) => text.includes(goFieldName) && text.includes(messageName), + token + ); +} + +async function findGoFieldAccessUsages( + goFieldName: string, + filePredicate?: (uri: vscode.Uri, text: string) => boolean, + token?: vscode.CancellationToken +): Promise { + return findGoUsagesInWorkspaceByText( + text => findGoFieldAccessUsagesInText(text, goFieldName), + MAX_USAGE_RESULTS, + filePredicate, + token + ); +} + +export async function getGoUsagesForProtoPosition( + doc: vscode.TextDocument, + pos: vscode.Position, + withProgress: boolean, + token?: vscode.CancellationToken +): Promise { + const strings = getStrings(); + const fieldCtx = getProtoFieldContextAtPosition(doc, pos); + + if (fieldCtx?.kind === 'fieldName') { + const goField = toGoExportedName(fieldCtx.fieldName); + const messageName = fieldCtx.messageName; + const findTask = async (searchToken?: vscode.CancellationToken) => { + if (messageName && messageName.length > 0) { + const goPkgInfo = await resolveGoPackageInfoForProtoFile(doc, searchToken); + const filePredicate = (_uri: vscode.Uri, text: string) => + text.includes(goField) && + ( + text.includes(messageName) || + (goPkgInfo?.packageName ? text.includes(goPkgInfo.packageName) : false) || + (goPkgInfo?.importPath ? text.includes(goPkgInfo.importPath) : false) + ); + + const [varUsages, composite] = await Promise.all([ + findGoVariableFieldUsages(messageName, goField, goPkgInfo, searchToken), + findGoCompositeFieldUsages(messageName, goField, goPkgInfo, searchToken) + ]); + if (isCancellationRequested(searchToken)) return mergeGoUsageResults(varUsages, composite).slice(0, MAX_USAGE_RESULTS); + const fallback = await findGoFieldAccessUsages(goField, filePredicate, searchToken); + const merged = mergeGoUsageResults(varUsages, composite, fallback).slice(0, MAX_USAGE_RESULTS); + if (merged.length > 0) return merged; + } + return findGoFieldAccessUsages( + goField, + messageName && messageName.length > 0 + ? (_uri, text) => text.includes(goField) && text.includes(messageName) + : (_uri, text) => text.includes(goField), + searchToken + ); + }; + + const combined = withProgress + ? await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: strings.searchingGoUsages, cancellable: true }, + (_progress, progressToken) => findTask(progressToken) + ) + : await findTask(token); + return combined.slice(0, MAX_REFERENCE_LOCATIONS).map(m => new vscode.Location(m.uri, m.range)); + } + + if (fieldCtx?.kind === 'fieldType') { + const goTypeName = fieldCtx.goTypeName ?? fieldCtx.typeName; + const findTask = (searchToken?: vscode.CancellationToken) => findGoUsagesPreferQualifiedName(doc, goTypeName, searchToken); + const matches = withProgress + ? await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: strings.searchingGoUsages, cancellable: true }, + (_progress, progressToken) => findTask(progressToken) + ) + : await findTask(token); + const goLocations = matches.map(m => new vscode.Location(m.uri, m.range)); + const protoLocations = findProtoSymbolLocationsInDocument(doc, fieldCtx.typeName); + return mergeLocations(protoLocations, goLocations).slice(0, MAX_REFERENCE_LOCATIONS); + } + + const symbol = getProtoDefinitionSymbolAtPosition(doc, pos); + if (symbol) { + const goSymbolName = symbol.kind === 'message' ? symbol.fullName ?? symbol.name : symbol.name; + const findTask = (searchToken?: vscode.CancellationToken) => findGoUsagesPreferQualifiedName(doc, goSymbolName, searchToken); + const matches = withProgress + ? await vscode.window.withProgress( + { location: vscode.ProgressLocation.Notification, title: strings.searchingGoUsages, cancellable: true }, + (_progress, progressToken) => findTask(progressToken) + ) + : await findTask(token); + const goLocations = matches.map(m => new vscode.Location(m.uri, m.range)); + const protoLocations = findProtoSymbolLocationsInDocument(doc, symbol.name); + return mergeLocations(protoLocations, goLocations).slice(0, MAX_REFERENCE_LOCATIONS); + } + + return undefined; +} + +export class ProtoGoDefinitionProvider implements vscode.DefinitionProvider { + async provideDefinition(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise { + const locations = await getGoUsagesForProtoPosition(document, position, false, token); + return locations || []; + } +} diff --git a/src/i18n.ts b/src/i18n.ts index 1223998..218e031 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -57,6 +57,11 @@ export type Strings = { unsaved: string; openJson: string; testCommand: string; + testNavigation: string; + openOutput: string; + renderedCommandPreview: string; + renderedCommandPreviewNeedProto: string; + renderedCommandPreviewNeedContext: string; addProtoRoot: string; noProtoRoots: string; notConfigured: string; @@ -68,6 +73,12 @@ export type Strings = { goToProtoDefinition: string; goToGoUsage: string; compileCurrentProto: string; + diagnoseCurrentSymbol: string; + testNavigationDone: string; + testNavigationNeedEditor: string; + testNavigationUnsupported: string; + testNavigationResolved: string; + testNavigationNoResult: string; editMakeProtoRule: string; language: string; languageChinese: string; @@ -96,6 +107,7 @@ export type Strings = { testMakeProtoRuleNeedActiveProto: string; testMakeProtoRuleDone: string; testMakeProtoRuleFailed: string; + diagnoseCurrentSymbolDone: string; }; export function getStrings(lang: UiLanguage = getUiLanguage()): Strings { @@ -145,6 +157,11 @@ export function getStrings(lang: UiLanguage = getUiLanguage()): Strings { unsaved: '未保存', openJson: '展开到 JSON', testCommand: '测试命令', + testNavigation: '测试跳转', + openOutput: '打开输出', + renderedCommandPreview: '展开后的命令', + renderedCommandPreviewNeedProto: '打开 .proto 文件后显示展开结果', + renderedCommandPreviewNeedContext: '当前 .proto 不在可识别的 proto_src / protoRoots 下', addProtoRoot: '添加 Proto 根目录', noProtoRoots: '暂无 Proto 根目录', notConfigured: '未配置', @@ -156,6 +173,12 @@ export function getStrings(lang: UiLanguage = getUiLanguage()): Strings { goToProtoDefinition: '跳转到 Proto 定义', goToGoUsage: '跳转到 Go 使用处', compileCurrentProto: '编译当前 Proto', + diagnoseCurrentSymbol: '诊断当前符号', + testNavigationDone: 'JumpProto: 测试跳转结果已写入输出面板。', + testNavigationNeedEditor: 'JumpProto: 请先打开 Go 或 .proto 文件并将光标放在符号上。', + testNavigationUnsupported: 'JumpProto: 测试跳转仅支持 Go 和 .proto 文件。', + testNavigationResolved: 'JumpProto: 已解析到结果,未执行跳转。', + testNavigationNoResult: 'JumpProto: 未解析到跳转结果。', editMakeProtoRule: '编辑 Make Proto 规则', language: '语言', languageChinese: '中文', @@ -183,7 +206,8 @@ export function getStrings(lang: UiLanguage = getUiLanguage()): Strings { makeProtoRuleEmpty: 'JumpProto: Make Proto 规则为空,请先填写。', testMakeProtoRuleNeedActiveProto: 'JumpProto: 请先打开一个 .proto 文件用于测试命令。', testMakeProtoRuleDone: 'JumpProto: 测试通过(dry-run,仅校验命令模板和 shell 语法)。', - testMakeProtoRuleFailed: 'JumpProto: 测试失败。' + testMakeProtoRuleFailed: 'JumpProto: 测试失败。', + diagnoseCurrentSymbolDone: 'JumpProto: 诊断信息已写入输出面板。' }; } @@ -232,6 +256,11 @@ export function getStrings(lang: UiLanguage = getUiLanguage()): Strings { unsaved: 'Unsaved', openJson: 'Open JSON', testCommand: 'Test Command', + testNavigation: 'Test Navigation', + openOutput: 'Open Output', + renderedCommandPreview: 'Rendered Command', + renderedCommandPreviewNeedProto: 'Open a .proto file to preview the rendered command', + renderedCommandPreviewNeedContext: 'Current .proto is not under a recognized proto_src / protoRoots path', addProtoRoot: 'Add Proto Root', noProtoRoots: 'No Proto Roots', notConfigured: 'Not configured', @@ -243,6 +272,12 @@ export function getStrings(lang: UiLanguage = getUiLanguage()): Strings { goToProtoDefinition: 'Go to Proto Definition', goToGoUsage: 'Go to Go Usage', compileCurrentProto: 'Compile Current Proto', + diagnoseCurrentSymbol: 'Diagnose Current Symbol', + testNavigationDone: 'JumpProto: Test navigation result written to the output panel.', + testNavigationNeedEditor: 'JumpProto: Open a Go or .proto file and place the cursor on a symbol first.', + testNavigationUnsupported: 'JumpProto: Test navigation supports Go and .proto files only.', + testNavigationResolved: 'JumpProto: Resolved a result without jumping.', + testNavigationNoResult: 'JumpProto: No navigation result resolved.', editMakeProtoRule: 'Edit Make Proto Rule', language: 'Language', languageChinese: '中文', @@ -270,6 +305,7 @@ export function getStrings(lang: UiLanguage = getUiLanguage()): Strings { makeProtoRuleEmpty: 'JumpProto: Make proto rule is empty. Fill it first.', testMakeProtoRuleNeedActiveProto: 'JumpProto: Open a .proto file first to test the command.', testMakeProtoRuleDone: 'JumpProto: Test passed (dry-run; template and shell syntax only).', - testMakeProtoRuleFailed: 'JumpProto: Test failed.' + testMakeProtoRuleFailed: 'JumpProto: Test failed.', + diagnoseCurrentSymbolDone: 'JumpProto: Diagnostics written to the output panel.' }; } diff --git a/src/integration.test.ts b/src/integration.test.ts new file mode 100644 index 0000000..63eebcc --- /dev/null +++ b/src/integration.test.ts @@ -0,0 +1,137 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import test from 'node:test'; + +import { extractProtoPathFromPbGo, findProtoSymbolMatch } from './core'; +import { + findGoCompositeFieldUsagesInText, + findGoFieldAccessUsagesInText, + findGoSymbolUsagesInText, + findGoVariableFieldUsagesInText, + findImportAliases, + parseGoPackageInfo +} from './goText'; +import { resolveProtoSrcRootPath } from './pathResolver'; +import { findProtoFieldContextAtOffset } from './protoScanner'; + +const fixturesRoot = path.join(process.cwd(), 'test', 'fixtures'); +const workspaceRoot = path.join(fixturesRoot, 'workspace'); +const protoRoot = path.join(workspaceRoot, 'proto_src'); +const externalProtoRoot = path.join(fixturesRoot, 'external_proto'); + +function readFixture(...parts: string[]): string { + return fs.readFileSync(path.join(fixturesRoot, ...parts), 'utf8'); +} + +test('fixture resolves generated Go source header back to proto symbols', () => { + const pbGo = readFixture('workspace', 'gen', 'activitypb', 'user_profile.pb.go'); + const protoPath = extractProtoPathFromPbGo(pbGo); + assert.equal(protoPath, 'api/activity/user_profile.proto'); + + const proto = readFixture('workspace', 'proto_src', protoPath!); + const message = findProtoSymbolMatch(proto, 'UserProfile'); + const enumMatch = findProtoSymbolMatch(proto, 'Status'); + const rpc = findProtoSymbolMatch(proto, 'GetUserProfile'); + const fieldAfterNested = findProtoSymbolMatch(proto, 'UserName', 'UserProfile'); + const nestedField = findProtoSymbolMatch(proto, 'NickName', 'UserProfile_Detail'); + const nestedMessage = findProtoSymbolMatch(proto, 'UserProfile_Detail'); + + assert.equal(message?.kind, 'message'); + assert.equal(enumMatch?.kind, 'enum'); + assert.equal(rpc?.kind, 'rpc'); + assert.equal(nestedMessage?.kind, 'message'); + assert.equal(proto.slice(nestedMessage!.startOffset, nestedMessage!.endOffset), 'Detail'); + assert.equal(proto.slice(fieldAfterNested!.startOffset, fieldAfterNested!.endOffset), 'user_name'); + assert.equal(proto.slice(nestedField!.startOffset, nestedField!.endOffset), 'nick_name'); +}); + +test('fixture resolves proto roots from configured external roots and proto_src fallback', () => { + const externalProto = path.join(externalProtoRoot, 'shared', 'external.proto'); + assert.equal(resolveProtoSrcRootPath(externalProto, [externalProtoRoot]), externalProtoRoot); + + const workspaceProto = path.join(protoRoot, 'api', 'activity', 'user_profile.proto'); + assert.equal(resolveProtoSrcRootPath(workspaceProto, []), protoRoot); +}); + +test('fixture finds Go usage forms for aliases, default imports, and same-package bare names', () => { + const proto = readFixture('workspace', 'proto_src', 'api', 'activity', 'user_profile.proto'); + const goPkg = parseGoPackageInfo(proto); + assert.deepEqual(goPkg, { + packageName: 'activitypb', + importPath: 'example.com/project/gen/activitypb' + }); + + const aliasedUsage = readFixture('workspace', 'service', 'user_service.go'); + const defaultImportUsage = readFixture('workspace', 'service', 'default_import.go'); + const samePackageUsage = readFixture('workspace', 'gen', 'activitypb', 'helper.go'); + + assert.deepEqual(findImportAliases(aliasedUsage, goPkg!.importPath!, goPkg!.packageName), ['apb']); + assert.deepEqual(findImportAliases(defaultImportUsage, goPkg!.importPath!, goPkg!.packageName), ['activitypb']); + + const aliasMatches = findGoSymbolUsagesInText(aliasedUsage, 'UserProfile', goPkg); + const qualifiedMatches = findGoSymbolUsagesInText(defaultImportUsage, 'UserProfile', goPkg); + const bareMatches = findGoSymbolUsagesInText(samePackageUsage, 'UserProfile', goPkg); + + assert.ok(aliasMatches.some(match => match.kind === 'alias' && match.text.includes('apb.UserProfile'))); + assert.ok(qualifiedMatches.some(match => match.kind === 'qualified' && match.text.includes('activitypb.UserProfile'))); + assert.ok(bareMatches.some(match => match.kind === 'bare' && match.text.includes('&UserProfile'))); +}); + +test('fixture finds structured Go field usages for composites, typed variables, and getters', () => { + const proto = readFixture('workspace', 'proto_src', 'api', 'activity', 'user_profile.proto'); + const goPkg = parseGoPackageInfo(proto); + const aliasedUsage = readFixture('workspace', 'service', 'user_service.go'); + + const composites = findGoCompositeFieldUsagesInText(aliasedUsage, 'UserProfile', 'UserName', goPkg); + const variableUsages = findGoVariableFieldUsagesInText(aliasedUsage, 'UserProfile', 'UserName', goPkg); + const fieldAccesses = findGoFieldAccessUsagesInText(aliasedUsage, 'UserName'); + + assert.ok(composites.some(match => match.kind === 'compositeField' && match.text.includes('UserName:'))); + assert.ok(variableUsages.some(match => match.kind === 'selectorField' && match.text.includes('profile.UserName'))); + assert.ok(variableUsages.some(match => match.kind === 'getter' && match.text.includes('profile.GetUserName'))); + assert.ok(fieldAccesses.some(match => match.kind === 'getter' && match.text.includes('profile.GetUserName'))); +}); + +test('fixture derives nested proto field container names from the proto scanner', () => { + const proto = readFixture('workspace', 'proto_src', 'api', 'activity', 'user_profile.proto'); + const offset = proto.indexOf('nick_name'); + assert.notEqual(offset, -1); + + const ctx = findProtoFieldContextAtOffset(proto, offset); + assert.deepEqual(ctx, { + kind: 'fieldName', + fieldName: 'nick_name', + messageName: 'UserProfile_Detail' + }); +}); + +test('fixture derives nested proto field type Go names from the proto scanner', () => { + const proto = readFixture('workspace', 'proto_src', 'api', 'activity', 'user_profile.proto'); + const offset = proto.indexOf('Detail detail'); + assert.notEqual(offset, -1); + + const ctx = findProtoFieldContextAtOffset(proto, offset); + assert.deepEqual(ctx, { + kind: 'fieldType', + typeName: 'Detail', + goTypeName: 'UserProfile_Detail' + }); +}); + +test('Go token scanner ignores comments and strings when finding symbols', () => { + const goText = `package service + +// apb.UserProfile should not be counted. +var _ = "apb.UserProfile" +`; + const goPkg = { + packageName: 'activitypb', + importPath: 'example.com/project/gen/activitypb' + }; + + assert.deepEqual(findGoSymbolUsagesInText(goText, 'UserProfile', goPkg), []); +}); diff --git a/src/pathResolver.ts b/src/pathResolver.ts new file mode 100644 index 0000000..377a869 --- /dev/null +++ b/src/pathResolver.ts @@ -0,0 +1,32 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export function resolveProtoSrcRootPath( + protoFile: string, + protoRoots: string[], + hasMakefile: (dir: string) => boolean = dir => fs.existsSync(path.join(dir, 'Makefile')) +): string | undefined { + const normalizedProtoFile = path.normalize(protoFile); + const matchingConfiguredRoots = protoRoots + .map(root => path.normalize(root)) + .filter(Boolean) + .filter(root => { + const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep; + return normalizedProtoFile === root || normalizedProtoFile.startsWith(rootWithSep); + }) + .sort((a, b) => b.length - a.length); + if (matchingConfiguredRoots.length > 0) return matchingConfiguredRoots[0]; + + let current = path.dirname(protoFile); + while (true) { + if (path.basename(current) === 'proto_src' && hasMakefile(current)) { + return current; + } + const parent = path.dirname(current); + if (parent === current) return undefined; + current = parent; + } +} diff --git a/src/protoResolver.ts b/src/protoResolver.ts new file mode 100644 index 0000000..d8f2461 --- /dev/null +++ b/src/protoResolver.ts @@ -0,0 +1,165 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; + +import { getConfig, getWorkspaceExcludeGlob } from './config'; +import { extractProtoPathFromPbGo, findProtoSymbolMatch } from './core'; +import { escapeForGlob, makeResolveKey } from './utils'; + +export type ResolveResult = { + protoUri: vscode.Uri; + targetRange: vscode.Range; +}; + +const resolvingKeys = new Set(); + +async function resolveProtoUri(protoPathFromPbGo: string): Promise { + if (path.isAbsolute(protoPathFromPbGo) && fs.existsSync(protoPathFromPbGo)) { + return vscode.Uri.file(protoPathFromPbGo); + } + + const config = getConfig(); + const { protoRoots, searchInWorkspace } = config; + + for (const root of protoRoots) { + const full = path.join(root, protoPathFromPbGo); + if (fs.existsSync(full)) { + return vscode.Uri.file(full); + } + } + + if (searchInWorkspace) { + const glob = `**/${escapeForGlob(protoPathFromPbGo)}`; + const matches = await vscode.workspace.findFiles(glob, getWorkspaceExcludeGlob(config), 5); + if (matches.length > 0) return matches[0]; + } + + return undefined; +} + +export async function resolveProtoDefinition(document: vscode.TextDocument, position: vscode.Position): Promise { + const wordRange = document.getWordRangeAtPosition(position, /[A-Za-z_][A-Za-z0-9_]*/); + if (!wordRange) return undefined; + const symbolName = document.getText(wordRange); + if (!symbolName) return undefined; + + const defs = await vscode.commands.executeCommand( + 'vscode.executeDefinitionProvider', + document.uri, + position + ); + + if (!defs || defs.length === 0) return undefined; + + const pbGoDef = defs.find(d => { + const uri = 'targetUri' in d ? (d as vscode.LocationLink).targetUri : (d as vscode.Location).uri; + return uri.fsPath.endsWith('.pb.go') || uri.fsPath.endsWith('.pb.gw.go'); + }); + + if (!pbGoDef) return undefined; + + const pbGoUri = 'targetUri' in pbGoDef ? (pbGoDef as vscode.LocationLink).targetUri : (pbGoDef as vscode.Location).uri; + const pbGoRange = 'targetRange' in pbGoDef ? (pbGoDef as vscode.LocationLink).targetRange : (pbGoDef as vscode.Location).range; + const defPath = pbGoUri.fsPath; + + let pbGoText: string; + try { + pbGoText = fs.readFileSync(defPath, 'utf8'); + } catch { + return undefined; + } + + let containerName: string | undefined; + const pbGoLines = pbGoText.split('\n'); + const defLineIndex = pbGoRange.start.line; + const defLine = pbGoLines[defLineIndex]; + + if (defLine.includes('`protobuf:')) { + for (let i = defLineIndex - 1; i >= 0 && i > defLineIndex - 100; i--) { + const line = pbGoLines[i]; + const structMatch = line.match(/^type\s+([A-Za-z_][A-Za-z0-9_]*)\s+struct\s*\{/); + if (structMatch) { + containerName = structMatch[1]; + break; + } + } + } + + const protoPathFromPbGo = extractProtoPathFromPbGo(pbGoText); + if (!protoPathFromPbGo) return undefined; + + const protoUri = await resolveProtoUri(protoPathFromPbGo); + if (!protoUri) return undefined; + + const protoDoc = await vscode.workspace.openTextDocument(protoUri); + const protoText = protoDoc.getText(); + + const match = findProtoSymbolMatch(protoText, symbolName, containerName); + const range = match + ? new vscode.Range(protoDoc.positionAt(match.startOffset), protoDoc.positionAt(match.endOffset)) + : new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0)); + + return { protoUri, targetRange: range }; +} + +export async function goToProtoDefinition(editor: vscode.TextEditor): Promise { + const document = editor.document; + const position = editor.selection.active; + const key = makeResolveKey(document.uri, position); + if (resolvingKeys.has(key)) return false; + resolvingKeys.add(key); + let resolved: ResolveResult | undefined; + try { + resolved = await resolveProtoDefinition(document, position); + } finally { + resolvingKeys.delete(key); + } + if (!resolved) return false; + + const targetDoc = await vscode.workspace.openTextDocument(resolved.protoUri); + await vscode.window.showTextDocument(targetDoc, { selection: resolved.targetRange, preserveFocus: false, preview: true }); + return true; +} + +export async function provideGoDefinitionWithProtoFirst( + document: vscode.TextDocument, + position: vscode.Position +): Promise { + const key = makeResolveKey(document.uri, position); + if (resolvingKeys.has(key)) return undefined; + resolvingKeys.add(key); + let resolved: ResolveResult | undefined; + let nativeDefs: vscode.Location[] = []; + try { + const defs = await vscode.commands.executeCommand( + 'vscode.executeDefinitionProvider', + document.uri, + position + ); + nativeDefs = (defs ?? []) + .map(d => 'targetUri' in d + ? new vscode.Location((d as vscode.LocationLink).targetUri, (d as vscode.LocationLink).targetSelectionRange ?? (d as vscode.LocationLink).targetRange) + : (d as vscode.Location)) + .filter(loc => loc.uri.fsPath.endsWith('.pb.go') || loc.uri.fsPath.endsWith('.pb.gw.go')); + resolved = await resolveProtoDefinition(document, position); + } finally { + resolvingKeys.delete(key); + } + if (!resolved) return undefined; + + const protoLocation = new vscode.Location(resolved.protoUri, resolved.targetRange); + const ordered: vscode.Location[] = [protoLocation]; + const seen = new Set([ + `${protoLocation.uri.toString()}#${protoLocation.range.start.line}:${protoLocation.range.start.character}-${protoLocation.range.end.line}:${protoLocation.range.end.character}` + ]); + for (const loc of nativeDefs) { + const key = `${loc.uri.toString()}#${loc.range.start.line}:${loc.range.start.character}-${loc.range.end.line}:${loc.range.end.character}`; + if (seen.has(key)) continue; + seen.add(key); + ordered.push(loc); + } + return ordered; +} diff --git a/src/protoScanner.ts b/src/protoScanner.ts new file mode 100644 index 0000000..1c7aec3 --- /dev/null +++ b/src/protoScanner.ts @@ -0,0 +1,424 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +export type ProtoSymbolKind = 'message' | 'enum' | 'rpc' | 'service' | 'field'; + +export type ProtoSymbol = { + name: string; + kind: ProtoSymbolKind; + startOffset: number; + endOffset: number; + containerName?: string; + fullName?: string; + typeName?: { + name: string; + goName?: string; + startOffset: number; + endOffset: number; + }; +}; + +export type ProtoFieldContext = + | { kind: 'fieldName'; fieldName: string; messageName: string } + | { kind: 'fieldType'; typeName: string; goTypeName?: string }; + +type Token = { + type: 'identifier' | 'number' | 'punctuation'; + value: string; + startOffset: number; + endOffset: number; +}; + +export type ProtoBlock = { + kind: 'message' | 'enum' | 'service'; + name: string; + fullName: string; + startOffset: number; + nameStartOffset: number; + nameEndOffset: number; + bodyStartOffset: number; + bodyEndOffset: number; +}; + +const PRIMITIVE_PROTO_TYPES = new Set([ + 'double', + 'float', + 'int32', + 'int64', + 'uint32', + 'uint64', + 'sint32', + 'sint64', + 'fixed32', + 'fixed64', + 'sfixed32', + 'sfixed64', + 'bool', + 'string', + 'bytes' +]); + +export function findProtoDeclarationSymbol(protoText: string, symbolName: string): ProtoSymbol | undefined { + const symbols = scanProtoSymbols(protoText); + const kindOrder: ProtoSymbolKind[] = ['message', 'enum', 'rpc', 'service']; + for (const kind of kindOrder) { + const match = symbols.find(symbol => + symbol.kind === kind && (symbol.name === symbolName || symbol.fullName === symbolName) + ); + if (match) return match; + } + return undefined; +} + +export function findProtoFieldSymbol( + protoText: string, + symbolName: string, + containerName: string +): ProtoSymbol | undefined { + const tokens = tokenizeProto(protoText); + const blocks = scanProtoBlocks(tokens); + const targetMessage = blocks.find(block => + block.kind === 'message' && (block.fullName === containerName || block.name === containerName) + ); + if (!targetMessage) return undefined; + + for (const field of scanMessageFields(tokens, blocks, targetMessage)) { + if ( + field.name === symbolName || + field.name.toLowerCase() === symbolName.toLowerCase() || + toGoExportedName(field.name) === symbolName + ) { + return field; + } + } + + return undefined; +} + +export function findProtoDeclarationAtOffset(protoText: string, offset: number): ProtoSymbol | undefined { + return scanProtoSymbols(protoText).find(symbol => + symbol.kind !== 'field' && offset >= symbol.startOffset && offset <= symbol.endOffset + ); +} + +export function findProtoFieldContextAtOffset(protoText: string, offset: number): ProtoFieldContext | undefined { + const tokens = tokenizeProto(protoText); + const blocks = scanProtoBlocks(tokens); + + for (const block of blocks) { + if (block.kind !== 'message') continue; + for (const field of scanMessageFields(tokens, blocks, block)) { + if (offset >= field.startOffset && offset <= field.endOffset) { + return { kind: 'fieldName', fieldName: field.name, messageName: block.fullName }; + } + if (field.typeName && field.typeName.startOffset <= offset && offset <= field.typeName.endOffset) { + return { kind: 'fieldType', typeName: field.typeName.name, goTypeName: field.typeName.goName }; + } + } + } + + return undefined; +} + +export function scanProtoSymbols(protoText: string): ProtoSymbol[] { + const tokens = tokenizeProto(protoText); + const blocks = scanProtoBlocks(tokens); + const symbols: ProtoSymbol[] = blocks.map(block => ({ + name: block.name, + kind: block.kind, + startOffset: block.nameStartOffset, + endOffset: block.nameEndOffset, + containerName: block.kind === 'message' ? getParentMessageName(block, blocks) : undefined, + fullName: block.fullName + })); + + for (let i = 1; i < tokens.length; i += 1) { + const token = tokens[i - 1]; + const nameToken = tokens[i]; + if (token.value !== 'rpc' || nameToken.type !== 'identifier') continue; + symbols.push({ + name: nameToken.value, + kind: 'rpc', + startOffset: nameToken.startOffset, + endOffset: nameToken.endOffset, + containerName: findEnclosingBlockName(nameToken.startOffset, blocks, 'service') + }); + } + + for (const block of blocks) { + if (block.kind !== 'message') continue; + symbols.push(...scanMessageFields(tokens, blocks, block)); + } + + return symbols; +} + +type ProtoFieldSymbol = ProtoSymbol & { kind: 'field' }; + +function scanMessageFields(tokens: Token[], blocks: ProtoBlock[], targetMessage: ProtoBlock): ProtoFieldSymbol[] { + const nestedDeclarationRanges = blocks + .filter(block => block.startOffset > targetMessage.bodyStartOffset && block.bodyEndOffset < targetMessage.bodyEndOffset) + .map(block => ({ start: block.startOffset, end: block.bodyEndOffset + 1 })); + const isInsideNestedDeclaration = (offset: number) => + nestedDeclarationRanges.some(range => offset >= range.start && offset < range.end); + + const fields: ProtoFieldSymbol[] = []; + for (let i = 1; i < tokens.length - 1; i += 1) { + const token = tokens[i]; + if (token.value !== '=') continue; + if (tokens[i + 1]?.type !== 'number') continue; + + const fieldToken = tokens[i - 1]; + if (fieldToken.type !== 'identifier') continue; + if (fieldToken.startOffset <= targetMessage.bodyStartOffset || fieldToken.endOffset >= targetMessage.bodyEndOffset) continue; + if (isInsideNestedDeclaration(fieldToken.startOffset)) continue; + + const typeName = findFieldTypeName(tokens, i - 1, blocks); + fields.push({ + name: fieldToken.value, + kind: 'field', + startOffset: fieldToken.startOffset, + endOffset: fieldToken.endOffset, + containerName: targetMessage.fullName, + typeName + }); + } + + return fields; +} + +function scanProtoBlocks(tokens: Token[]): ProtoBlock[] { + const rawBlocks: Array> = []; + for (let i = 0; i < tokens.length - 2; i += 1) { + const keyword = tokens[i]; + if (keyword.type !== 'identifier' || !isBlockKeyword(keyword.value)) continue; + + const name = tokens[i + 1]; + if (name.type !== 'identifier') continue; + + const openBraceIndex = findNextTokenIndex(tokens, i + 2, '{'); + if (openBraceIndex === undefined) continue; + const closeBraceIndex = findMatchingBraceTokenIndex(tokens, openBraceIndex); + if (closeBraceIndex === undefined) continue; + + rawBlocks.push({ + kind: keyword.value, + name: name.value, + startOffset: keyword.startOffset, + nameStartOffset: name.startOffset, + nameEndOffset: name.endOffset, + bodyStartOffset: tokens[openBraceIndex].endOffset, + bodyEndOffset: tokens[closeBraceIndex].startOffset + }); + } + + const blocks: ProtoBlock[] = rawBlocks.map(block => ({ ...block, fullName: block.name })); + for (const block of blocks) { + if (block.kind !== 'message') continue; + const parent = blocks + .filter(candidate => + candidate.kind === 'message' && + candidate.startOffset < block.startOffset && + candidate.bodyEndOffset > block.startOffset + ) + .sort((a, b) => b.startOffset - a.startOffset)[0]; + block.fullName = parent ? `${parent.fullName}_${block.name}` : block.name; + } + return blocks; +} + +function findFieldTypeName( + tokens: Token[], + fieldNameIndex: number, + blocks: ProtoBlock[] +): { name: string; goName?: string; startOffset: number; endOffset: number } | undefined { + const prev = tokens[fieldNameIndex - 1]; + if (!prev) return undefined; + + if (prev.value === '>') { + const openIndex = findMatchingAngleOpenTokenIndex(tokens, fieldNameIndex - 1); + if (openIndex === undefined || tokens[openIndex - 1]?.value !== 'map') return undefined; + const commaIndex = findTopLevelCommaTokenIndex(tokens, openIndex + 1, fieldNameIndex - 1); + if (commaIndex === undefined) return undefined; + for (let i = fieldNameIndex - 2; i > commaIndex; i -= 1) { + const token = tokens[i]; + if (token.type !== 'identifier') continue; + if (PRIMITIVE_PROTO_TYPES.has(token.value)) return undefined; + return { + name: token.value, + goName: resolveProtoMessageGoName(token.value, blocks), + startOffset: token.startOffset, + endOffset: token.endOffset + }; + } + return undefined; + } + + if (prev.type !== 'identifier') return undefined; + if (PRIMITIVE_PROTO_TYPES.has(prev.value)) return undefined; + if (prev.value === 'repeated' || prev.value === 'optional' || prev.value === 'required') return undefined; + return { + name: prev.value, + goName: resolveProtoMessageGoName(prev.value, blocks), + startOffset: prev.startOffset, + endOffset: prev.endOffset + }; +} + +function resolveProtoMessageGoName(typeName: string, blocks: ProtoBlock[]): string | undefined { + const matches = blocks.filter(block => block.kind === 'message' && block.name === typeName); + return matches.length === 1 ? matches[0].fullName : undefined; +} + +function findMatchingAngleOpenTokenIndex(tokens: Token[], closeIndex: number): number | undefined { + let depth = 0; + for (let i = closeIndex; i >= 0; i -= 1) { + if (tokens[i].value === '>') depth += 1; + if (tokens[i].value === '<') { + depth -= 1; + if (depth === 0) return i; + } + } + return undefined; +} + +function findTopLevelCommaTokenIndex(tokens: Token[], startIndex: number, endIndex: number): number | undefined { + let depth = 0; + for (let i = startIndex; i < endIndex; i += 1) { + const value = tokens[i].value; + if (value === '<') depth += 1; + if (value === '>') depth -= 1; + if (value === ',' && depth === 0) return i; + } + return undefined; +} + +function getParentMessageName(block: ProtoBlock, blocks: ProtoBlock[]): string | undefined { + return blocks + .filter(candidate => + candidate.kind === 'message' && + candidate.startOffset < block.startOffset && + candidate.bodyEndOffset > block.startOffset + ) + .sort((a, b) => b.startOffset - a.startOffset)[0]?.fullName; +} + +function findEnclosingBlockName( + offset: number, + blocks: ProtoBlock[], + kind: ProtoBlock['kind'] +): string | undefined { + return blocks + .filter(block => block.kind === kind && block.bodyStartOffset <= offset && block.bodyEndOffset >= offset) + .sort((a, b) => b.startOffset - a.startOffset)[0]?.fullName; +} + +function tokenizeProto(protoText: string): Token[] { + const tokens: Token[] = []; + let i = 0; + + while (i < protoText.length) { + const ch = protoText[i]; + const next = protoText[i + 1]; + + if (ch === '/' && next === '/') { + i += 2; + while (i < protoText.length && protoText[i] !== '\n') i += 1; + continue; + } + if (ch === '/' && next === '*') { + i += 2; + while (i < protoText.length && !(protoText[i] === '*' && protoText[i + 1] === '/')) i += 1; + i = Math.min(i + 2, protoText.length); + continue; + } + if (ch === '"' || ch === "'") { + i = skipQuotedString(protoText, i); + continue; + } + if (isIdentifierStart(ch)) { + const start = i; + i += 1; + while (i < protoText.length && isIdentifierPart(protoText[i])) i += 1; + tokens.push({ type: 'identifier', value: protoText.slice(start, i), startOffset: start, endOffset: i }); + continue; + } + if (isDigit(ch)) { + const start = i; + i += 1; + while (i < protoText.length && isDigit(protoText[i])) i += 1; + tokens.push({ type: 'number', value: protoText.slice(start, i), startOffset: start, endOffset: i }); + continue; + } + if ('{}=;()<>[],.'.includes(ch)) { + tokens.push({ type: 'punctuation', value: ch, startOffset: i, endOffset: i + 1 }); + } + i += 1; + } + + return tokens; +} + +function skipQuotedString(text: string, quoteOffset: number): number { + const quote = text[quoteOffset]; + let i = quoteOffset + 1; + while (i < text.length) { + if (text[i] === '\\') { + i += 2; + continue; + } + if (text[i] === quote) return i + 1; + i += 1; + } + return i; +} + +function findNextTokenIndex(tokens: Token[], startIndex: number, value: string): number | undefined { + for (let i = startIndex; i < tokens.length; i += 1) { + const token = tokens[i]; + if (token.value === ';') return undefined; + if (token.value === value) return i; + } + return undefined; +} + +function findMatchingBraceTokenIndex(tokens: Token[], openBraceIndex: number): number | undefined { + let depth = 0; + for (let i = openBraceIndex; i < tokens.length; i += 1) { + const value = tokens[i].value; + if (value === '{') depth += 1; + if (value === '}') { + depth -= 1; + if (depth === 0) return i; + } + } + return undefined; +} + +function isBlockKeyword(value: string): value is ProtoBlock['kind'] { + return value === 'message' || value === 'enum' || value === 'service'; +} + +function isIdentifierStart(ch: string | undefined): boolean { + return !!ch && /[A-Za-z_]/.test(ch); +} + +function isIdentifierPart(ch: string | undefined): boolean { + return !!ch && /[A-Za-z0-9_]/.test(ch); +} + +function isDigit(ch: string | undefined): boolean { + return !!ch && /[0-9]/.test(ch); +} + +function toGoExportedName(protoName: string): string { + const parts = protoName.split('_').filter(Boolean); + let out = ''; + for (let i = 0; i < parts.length; i += 1) { + const seg = parts[i]; + const mapped = seg.length === 0 ? seg : seg[0].toUpperCase() + seg.slice(1); + if (i > 0 && /^\d/.test(seg)) out += '_'; + out += mapped; + } + return out; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..9e42b64 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,53 @@ +// Copyright 2026 JumpProto contributors. +// SPDX-License-Identifier: Apache-2.0 + +import * as vscode from 'vscode'; + +export function makeResolveKey(uri: vscode.Uri, position: vscode.Position): string { + return `${uri.toString()}::${position.line}:${position.character}`; +} + +export function escapeForGlob(p: string): string { + return p.replaceAll('\\', '/'); +} + +export function normalizeSlashes(p: string): string { + return p.replaceAll('\\', '/'); +} + +export function escapeForRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function escapeHtml(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +export function isTextEditor(arg: unknown): arg is vscode.TextEditor { + return !!arg && typeof arg === 'object' && 'document' in (arg as any) && 'selection' in (arg as any); +} + +export function countChar(s: string, ch: string): number { + let n = 0; + for (let i = 0; i < s.length; i += 1) { + if (s[i] === ch) n += 1; + } + return n; +} + +export function mergeLocations(primary: vscode.Location[], secondary: vscode.Location[]): vscode.Location[] { + const out: vscode.Location[] = []; + const seen = new Set(); + for (const loc of [...primary, ...secondary]) { + const key = `${loc.uri.toString()}#${loc.range.start.line}:${loc.range.start.character}-${loc.range.end.line}:${loc.range.end.character}`; + if (seen.has(key)) continue; + seen.add(key); + out.push(loc); + } + return out; +} diff --git a/src/view.ts b/src/view.ts index 8ee6dd7..b235acc 100644 --- a/src/view.ts +++ b/src/view.ts @@ -3,6 +3,11 @@ import * as vscode from 'vscode'; +import { + getMakeProtoTemplateValues, + previewMakeProtoCommand, + resolveProtoCompileContext +} from './compile'; import { getStrings, getUiLanguage } from './i18n'; function getConfig() { @@ -28,6 +33,10 @@ type ViewMessage = | { type: 'removeProtoRoot'; rootPath: string } | { type: 'toggleSearchInWorkspace' } | { type: 'selectLanguage' } + | { type: 'testNavigation' } + | { type: 'openOutput' } + | { type: 'compileCurrentProto' } + | { type: 'diagnoseCurrentSymbol' } | { type: 'openMakeProtoRuleHelp' } | { type: 'openMakeProtoRuleJson' } | { type: 'testMakeProtoRule'; value: string } @@ -62,6 +71,18 @@ export class ProtoJumpViewProvider implements vscode.WebviewViewProvider { case 'selectLanguage': void vscode.commands.executeCommand('protoJump.selectLanguage'); break; + case 'testNavigation': + void vscode.commands.executeCommand('protoJump.testNavigation'); + break; + case 'openOutput': + void vscode.commands.executeCommand('protoJump.openOutput'); + break; + case 'compileCurrentProto': + void vscode.commands.executeCommand('protoJump.compileCurrentProto'); + break; + case 'diagnoseCurrentSymbol': + void vscode.commands.executeCommand('protoJump.diagnoseCurrentSymbol'); + break; case 'openMakeProtoRuleHelp': void vscode.commands.executeCommand('protoJump.openMakeProtoRuleHelp'); break; @@ -85,6 +106,16 @@ export class ProtoJumpViewProvider implements vscode.WebviewViewProvider { const strings = getStrings(); const language = getUiLanguage(); const { protoRoots, searchInWorkspace, makeProtoCommand } = getConfig(); + const activeDoc = vscode.window.activeTextEditor?.document; + const compileCtx = activeDoc ? resolveProtoCompileContext(activeDoc) : undefined; + const templateValues = compileCtx ? getMakeProtoTemplateValues(compileCtx) : undefined; + const preview = previewMakeProtoCommand(makeProtoCommand, activeDoc); + const renderedPreview = preview.rendered + ?? (preview.reason === 'empty' + ? strings.makeProtoRuleUnset + : preview.reason === 'unresolvedContext' + ? strings.renderedCommandPreviewNeedContext + : strings.renderedCommandPreviewNeedProto); const nonce = String(Date.now()); const removeLabel = language === 'zh' ? '移除' : 'Remove'; @@ -381,6 +412,30 @@ export class ProtoJumpViewProvider implements vscode.WebviewViewProvider { overflow: hidden; } + .rendered-preview { + padding: 10px 11px; + border-radius: 10px; + border: 1px solid var(--line-soft); + background: color-mix(in srgb, var(--panel) 84%, transparent); + } + + .rendered-preview-label { + margin-bottom: 6px; + color: var(--muted); + font-size: 11px; + font-weight: 650; + } + + .rendered-preview code { + display: block; + color: var(--input-fg); + font-family: var(--mono); + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; + } + .list-item { display: grid; grid-template-columns: 1fr auto; @@ -465,6 +520,20 @@ export class ProtoJumpViewProvider implements vscode.WebviewViewProvider { +
+
+

${escapeHtml(strings.actions)}

+
+
+
+ + + + +
+
+
+

${escapeHtml(strings.protoRoots)}

@@ -499,6 +568,11 @@ export class ProtoJumpViewProvider implements vscode.WebviewViewProvider {
+
+
${escapeHtml(strings.renderedCommandPreview)}
+ ${escapeHtml(renderedPreview)} +
+
Ctrl/Cmd + Enter ${language === 'zh' ? '快速保存' : 'Quick Save'} · Esc ${language === 'zh' ? '取消编辑' : 'Cancel Edit'}
@@ -506,8 +580,19 @@ export class ProtoJumpViewProvider implements vscode.WebviewViewProvider {