✨ feat(vscode): 代码阅读标出项目写明的入口文件和入口函数

This commit is contained in:
cruldra committed 2026-10-08 07:17:25 +08:00
1 parent 78d5748b81
commit 718bbb7f6c
19 files changed
+761 -83

No files matched your search

@@ -1,4 +1,5 @@
import type { Memento } from 'vscode'
import type { BlockView } from '../blockView'
import type { LibraryRule } from '../contract'
import { describe, expect, it } from 'vitest'
import { packageCommandRules } from '../commands/packageCommands'
@@ -6,14 +7,16 @@ import { shellCommandRules } from '../commands/shellCommands'
import { createBashAdapter } from '../languages/bash'
import { createDockerfileAdapter } from '../languages/dockerfile'
import { createPythonAdapter } from '../languages/python'
import { createRustAdapter } from '../languages/rust'
import { createTypeScriptAdapter } from '../languages/typescript'
import { CodeReaderRegistry } from '../registry'
import { fastapiRule } from '../rules/fastapi'
import { sqlalchemyRule } from '../rules/sqlalchemy'
import { DOCKERFILE_WASM_DIR, runtime, WASM_DIR } from '../testSupport'
import { DOCKERFILE_WASM_DIR, propertyLines, runtime, WASM_DIR } from '../testSupport'
import { bashZhTranslator } from '../translators/bashZh'
import { dockerfileZhTranslator } from '../translators/dockerfileZh'
import { pythonZhTranslator } from '../translators/pythonZh'
import { rustZhTranslator } from '../translators/rustZh'
import { typescriptZhTranslator } from '../translators/typescriptZh'
import { documentMessage } from './documentMessage'
@@ -196,3 +199,44 @@ describe('预览的 document 消息', () => {
expect(message.error).toBe('语法包加载失败')
})
})
describe('入口文件的预览', () => {
const CLI = [
'import typer',
'',
'app = typer.Typer()',
'',
'',
'def main():',
' pass',
'',
'',
'def helper():',
' pass',
'',
].join('\n')
const nameRow = (block: BlockView): string => 'properties' in block ? propertyLines(block.properties)[0] : ''
it('名字对上的块(变量、函数都算),名称那一行名字后面加浅色的「入口」', async () => {
const { message } = await documentMessage({ ...doc('python', CLI), entryNames: ['app', 'main'] }, registryWithPython(), runtime, ignore)
expect(message.blocks.slice(1).map(nameRow)).toEqual(['名称: app 入口', '名称: main 入口', '名称: helper'])
const [, app] = message.blocks
expect('properties' in app && app.properties[0].value).toContainEqual({ text: ' 入口', role: 'hint' })
})
it('对不上名字、不是入口:什么都不加', async () => {
const plain = await documentMessage(doc('python', CLI), registryWithPython(), runtime, ignore)
const unmatched = await documentMessage({ ...doc('python', CLI), entryNames: ['run'] }, registryWithPython(), runtime, ignore)
expect(unmatched.message.blocks).toEqual(plain.message.blocks)
expect(plain.message.blocks.map(nameRow).join('\n')).not.toContain('入口')
})
it('rust:fn main 的名称后面加「入口」', async () => {
const registry = new CodeReaderRegistry({ get: () => undefined, update: async () => {} } as Pick<Memento, 'get' | 'update'>)
registry.registerLanguage(createRustAdapter(WASM_DIR))
registry.registerTranslator(rustZhTranslator)
const { message } = await documentMessage({ ...doc('rust', 'fn main() {\n run();\n}\n\nfn run() {}\n'), entryNames: ['main'] }, registry, runtime, ignore)
expect(message.blocks.map(nameRow)).toEqual(['名称: main 入口', '名称: run'])
})
})
@@ -5,6 +5,7 @@ import type { DocumentMessage } from './messages'
import { fileAttributes, mergeAttributeNames } from '../attributes'
import { readBlockViews } from '../blockView'
import { commandRuleLookup } from '../commandRules'
import { markEntryNames } from '../entries/entryMarks'
import { ruleAttributeNames } from '../libraryRules'
/** 生成预览数据要读的那点文档信息(TextDocument 的子集)。 */
@@ -12,6 +13,8 @@ export interface DocumentSnapshot {
languageId: string
version: number
getText: () => string
/** 这份文件是项目写明的入口时,预览里要标「入口」的名字(见 entries/entryPoints.ts) */
entryNames?: readonly string[]
}
/** 发给预览的文档,和这份源码里起了中文名的定义(扩展端接着找用到它们的各处)、语言插件自己认得出的名字(LocalSymbol)。 */
@@ -25,6 +28,7 @@ export interface DocumentRead {
* 当前文档 → 发给预览的 document 消息;语言不支持或解析失败时带 error,blocks 为空。库规则出错不算失败,交给 report 记下;
* 规则查名字的定义时问 lookup(RuleContext.lookup),翻译插件问调用的名字定义成哪种块时问 calleeKind(SourceDocument.calleeKind)。
* 筛选属性和值的名字先取翻译插件的,没给的再取库规则的(LibraryRule.attributeNames)。
* 入口文件里名字对上的块,名称那一行在翻译好的结果上加「入口」(markEntryNames)。
*/
export async function documentMessage(
document: DocumentSnapshot,
@@ -66,7 +70,7 @@ export async function documentMessage(
},
})
const names = mergeAttributeNames([translator?.attributeNames, ...ruleAttributeNames(rules, translator?.displayLanguage, report)])
return { message: { ...message, blocks, attributes: fileAttributes(blocks, names) }, displayNames, symbols }
return { message: { ...message, blocks: markEntryNames(blocks, source, document.entryNames ?? []), attributes: fileAttributes(blocks, names) }, displayNames, symbols }
}
catch (err) {
return { message: { ...message, error: err instanceof Error ? err.message : String(err) }, displayNames: [], symbols: [] }
+11 -3
View File
@@ -3,6 +3,7 @@ import type { ClaudeImage } from '../../cc/spawnClaude'
import type { BlockKind, LocalSymbol, RuleLocation, RuleReference, WorkspaceIndex } from '../contract'
import type { DiffBase } from '../diff/diffBase'
import type { OldDocument, SentDocument } from '../diff/previewDiff'
import type { WorkspaceEntries } from '../entries/workspaceEntries'
import type { DefinitionStart, IndexedName, NameSources } from '../nameResolution'
import type { CodeQa } from '../qa/codeQa'
import type { CodeReaderRegistry } from '../registry'
@@ -76,6 +77,8 @@ export class PreviewEditorProvider implements CustomTextEditorProvider {
readonly indexed: (index: WorkspaceIndex, key: string) => Promise<IndexedEntry[]>,
/** 预览标差异时对比什么 */
readonly diffBases: DiffBases,
/** 哪些文件是项目写明的入口、预览里标哪些名字 */
readonly entries: Pick<WorkspaceEntries, 'namesOf' | 'onDidChange'>,
) {}
/** 侧栏「问 AI」打开预览,并把光标放进顶部输入框。已经开着的就揭到前面再聚焦。 */
@@ -253,6 +256,11 @@ class Preview {
if (this.ready)
this.post({ type: 'filter', ...state })
}),
// 项目的入口变了:重新发文档,名称那一行的「入口」跟着变
owner.entries.onDidChange((uris) => {
if (uris.some(uri => uri.toString() === document.uri.toString()))
this.schedulePush()
}),
owner.qa.onDidChange((uris) => {
if (uris.some(uri => uri.toString() === this.subject().uri.toString()))
this.postQa()
@@ -320,7 +328,7 @@ class Preview {
const source = this.document.getText()
const session = this.lookups.session()
const callees = this.callees.session()
const { message, displayNames, symbols } = await documentMessage({ languageId, version, getText: () => source }, this.registry, this.runtime, (text) => {
const { message, displayNames, symbols } = await documentMessage({ languageId, version, getText: () => source, entryNames: this.owner.entries.namesOf(this.document.uri) }, this.registry, this.runtime, (text) => {
logger.add({ level: 'warn', source: 'codeReader', message: `${this.document.uri.fsPath}:${text}` })
}, session.lookup, callees.calleeKind)
if (request !== this.latest || !this.ready)
@@ -344,11 +352,11 @@ class Preview {
/**
* 对比基准里的旧版本排成视图数据:和这份文档同一种语言、同一个翻译插件和库规则;规则查定义、调用的种类用这份文档眼下查到的答案,
* 两边同样的写法翻得一样,不会因为答案晚到被当成改了。旧版本里规则出的错不记(记的是现在这份的)。
* 两边同样的写法翻得一样,不会因为答案晚到被当成改了;项目入口也按眼下的标,「入口」两边都有。旧版本里规则出的错不记(记的是现在这份的)。
*/
private async readOld(source: string): Promise<OldDocument | undefined> {
const { message } = await documentMessage(
{ languageId: this.document.languageId, version: this.document.version, getText: () => source },
{ languageId: this.document.languageId, version: this.document.version, getText: () => source, entryNames: this.owner.entries.namesOf(this.document.uri) },
this.registry,
this.runtime,
() => {},
@@ -0,0 +1,31 @@
import type { BlockView } from '../blockView'
import type { Segment } from '../contract'
/** 名称那一行、名字后面的浅色说明词。 */
const ENTRY_HINT: Segment = { text: ' 入口', role: 'hint' }
/**
* 入口文件的预览:文件最外层、名字是入口名字之一的那一块(函数、常量、变量、库规则的卡片,谁对上就是谁),
* 属性表里写着这个名字的那一行、名字后面加一个浅色的「入口」。和语言、翻译插件无关:
* 块的名字按它定义处(defines)在源码里的那一段认,属性表里按词的源码下标(offset)找写名字的那个词;对不上就什么都不加。
*/
export function markEntryNames(blocks: BlockView[], source: string, names: readonly string[]): BlockView[] {
if (names.length === 0)
return blocks
return blocks.map((block) => {
const at = block.defines
if (!at || !('properties' in block) || !names.includes(source.slice(at.startIndex, at.endIndex)))
return block
let marked = false
const properties = block.properties.map((row) => {
if (marked || 'markdown' in row.value)
return row
const index = row.value.findIndex(piece => 'role' in piece && piece.offset === at.startIndex)
if (index < 0)
return row
marked = true
return { ...row, value: [...row.value.slice(0, index + 1), ENTRY_HINT, ...row.value.slice(index + 1)] }
})
return marked ? { ...block, properties } : block
})
}
@@ -0,0 +1,149 @@
import type { FolderReader } from '../workspaceScope'
import { describe, expect, it } from 'vitest'
import { findEntryPoints } from './entryPoints'
/** 一个假的工作区文件夹:路径 → 内容;不读盘。 */
function folder(files: Record<string, string>): { paths: string[], reader: FolderReader } {
return {
paths: Object.keys(files),
reader: {
exists: async path => path in files,
read: async path => files[path],
},
}
}
async function entriesOf(files: Record<string, string>): Promise<Record<string, readonly string[]>> {
const { paths, reader } = folder(files)
return Object.fromEntries(await findEntryPoints(paths, reader))
}
describe('python:pyproject.toml 的 [project.scripts]、[project.gui-scripts]', () => {
it('src 布局:模块找到 src 下的文件,带上名字;一个文件可以有几个名字', async () => {
expect(await entriesOf({
'tools/agent/pyproject.toml': [
'[project]',
'name = "agent-cli"',
'[project.scripts]',
'agent = "agent_cli.main:app"',
'agent-dev = "agent_cli.main:dev [extra]"',
'[project.gui-scripts]',
'agent-gui = "agent_cli.gui:Window.run"',
].join('\n'),
'tools/agent/src/agent_cli/main.py': '',
'tools/agent/src/agent_cli/gui.py': '',
'tools/agent/agent_cli/main.py': '',
})).toEqual({
'tools/agent/src/agent_cli/main.py': ['app', 'dev'],
'tools/agent/src/agent_cli/gui.py': ['Window'],
})
})
it('按顺序找第一个存在的:src/模块.py、模块.py、src/模块/__init__.py、模块/__init__.py', async () => {
const scripts = (target: string): string => `[project.scripts]\ncli = "${target}"`
expect(await entriesOf({ 'pyproject.toml': scripts('pkg:main'), 'pkg.py': '', 'pkg/__init__.py': '' })).toEqual({ 'pkg.py': ['main'] })
expect(await entriesOf({ 'pyproject.toml': scripts('pkg:main'), 'src/pkg/__init__.py': '', 'pkg/__init__.py': '' })).toEqual({ 'src/pkg/__init__.py': ['main'] })
expect(await entriesOf({ 'pyproject.toml': scripts('pkg.cli'), 'pkg/cli/__init__.py': '' })).toEqual({ 'pkg/cli/__init__.py': [] })
})
it('模块文件不存在就不标;别的表、写坏的 toml 也不标', async () => {
expect(await entriesOf({ 'pyproject.toml': '[project.scripts]\ncli = "missing.module:main"', 'main.py': '' })).toEqual({})
expect(await entriesOf({ 'pyproject.toml': '[tool.poetry.scripts]\ncli = "app:main"', 'app.py': '' })).toEqual({})
expect(await entriesOf({ 'pyproject.toml': '[project.scripts\ncli = "app:main"', 'app.py': '' })).toEqual({})
})
})
describe('rust:Cargo.toml', () => {
it('src/main.rs 标 main,src/lib.rs 不标', async () => {
expect(await entriesOf({
'crates/server/Cargo.toml': '[package]\nname = "server"',
'crates/server/src/main.rs': 'fn main() {}',
'crates/server/src/lib.rs': '',
'crates/core/Cargo.toml': '[package]\nname = "core"',
'crates/core/src/lib.rs': '',
})).toEqual({ 'crates/server/src/main.rs': ['main'] })
})
it('[[bin]] 写了 path 的标那个路径(相对 Cargo.toml 所在目录),不存在的不标', async () => {
expect(await entriesOf({
'app/Cargo.toml': [
'[package]',
'name = "app"',
'[[bin]]',
'name = "migrate"',
'path = "tools/migrate.rs"',
'[[bin]]',
'name = "gone"',
'path = "tools/gone.rs"',
'[[bin]]',
'name = "auto"',
].join('\n'),
'app/tools/migrate.rs': '',
})).toEqual({ 'app/tools/migrate.rs': ['main'] })
})
})
describe('go:go.mod', () => {
const main = 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("hi")\n}\n'
it('package main 又有 func main 的标 main,普通包不标', async () => {
expect(await entriesOf({
'go.mod': 'module example.com/app',
'cmd/server/main.go': main,
'internal/run/run.go': 'package run\n\nfunc main() {}\n',
'cmd/server/flags.go': 'package main\n\nvar port = 8080\n',
})).toEqual({ 'cmd/server/main.go': ['main'] })
})
it('方法、注释和字符串里的 func main 不算;包名前面可以有注释', async () => {
expect(await entriesOf({
'go.mod': 'module example.com/app',
'a.go': '// 生成代码的模板\npackage main\n\nconst tpl = `\nfunc main() {}\n`\n\ntype T struct{}\n\nfunc (T) main() {}\n// func main()\n',
'b.go': '/* 说明 */\n// +build tools\npackage main\n\nfunc init() { s := "{"; _ = s }\n\nfunc main() {}\n',
})).toEqual({ 'b.go': ['main'] })
})
it('只看自己模块的目录:别的 go.mod 的子目录归那个模块,vendor 不进,没有 go.mod 的不看', async () => {
expect(await entriesOf({
'svc/go.mod': 'module example.com/svc',
'svc/main.go': main,
'svc/tools/go.mod': 'module example.com/tools',
'svc/tools/gen/main.go': main,
'svc/vendor/example.com/dep/main.go': main,
'scripts/main.go': main,
})).toEqual({ 'svc/main.go': ['main'], 'svc/tools/gen/main.go': ['main'] })
})
})
describe('javascript / typescript:package.json 的 bin', () => {
it('对象:每个命令的文件,只标文件不带名字', async () => {
expect(await entriesOf({
'packages/cli/package.json': JSON.stringify({ name: 'cli', main: 'index.js', bin: { 'spx': './bin/spx.js', 'spx-dev': 'bin/dev.js' } }),
'packages/cli/bin/spx.js': '',
'packages/cli/bin/dev.js': '',
'packages/cli/index.js': '',
})).toEqual({ 'packages/cli/bin/spx.js': [], 'packages/cli/bin/dev.js': [] })
})
it('字符串:就是那个文件;main、exports、scripts 不看', async () => {
expect(await entriesOf({
'package.json': JSON.stringify({ bin: 'cli.mjs', main: 'lib.js', exports: './lib.js', scripts: { start: 'node server.js' } }),
'cli.mjs': '',
'lib.js': '',
'server.js': '',
})).toEqual({ 'cli.mjs': [] })
})
})
describe('不进的目录', () => {
it('node_modules、.venv、vendor、dist、__pycache__ 里的清单不看', async () => {
expect(await entriesOf({
'node_modules/tool/package.json': JSON.stringify({ bin: 'cli.js' }),
'node_modules/tool/cli.js': '',
'.venv/lib/pkg/pyproject.toml': '[project.scripts]\ncli = "pkg:main"',
'.venv/lib/pkg/pkg.py': '',
'dist/package.json': JSON.stringify({ bin: 'cli.js' }),
'dist/cli.js': '',
})).toEqual({})
})
})
@@ -0,0 +1,236 @@
import type { FolderReader } from '../workspaceScope'
import * as path from 'node:path'
import { parse as parseToml } from 'toml'
import { normalizeKey } from '../pathMap'
/**
* 一个工作区文件夹里各项目自己写明的入口:入口文件(相对工作区文件夹,`/` 分隔)→ 预览里要标的名字。
* 名字是空的只标文件(package.json 的 `bin` 写的是文件,没有函数名)。
*/
export type EntryPoints = ReadonlyMap<string, readonly string[]>
/** 不进的目录:依赖、虚拟环境、构建产物、字节码缓存、git 自己的目录。 */
export const SKIPPED_DIRS: readonly string[] = ['node_modules', '.venv', 'vendor', 'dist', '__pycache__', '.git']
/** 交给 findFiles 的排除 glob,和 SKIPPED_DIRS 是同一组目录。 */
export const SKIPPED_GLOB = `**/{${SKIPPED_DIRS.join(',')}}/**`
/** 要交给 findEntryPoints 的文件:四种项目的清单,加上 Go 源码(看 `package main` 和 `func main`)。 */
export const ENTRY_SOURCES = '**/{pyproject.toml,Cargo.toml,go.mod,package.json,*.go}'
interface Entry { path: string, names: readonly string[] }
type Table = Record<string, unknown>
function isTable(value: unknown): value is Table {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** path 是否在不进的目录里(见 SKIPPED_DIRS)。 */
export function isSkipped(file: string): boolean {
return file.split('/').slice(0, -1).some(dir => SKIPPED_DIRS.includes(dir))
}
/** 清单所在目录下的 file,换成相对工作区文件夹的路径;跑出工作区文件夹的不要。 */
function inDir(manifest: string, file: string): string | undefined {
return normalizeKey(path.posix.join(path.posix.dirname(manifest), file))
}
/** 按顺序第一个存在的。 */
async function firstExisting(candidates: readonly (string | undefined)[], reader: FolderReader): Promise<string | undefined> {
for (const candidate of candidates) {
if (candidate !== undefined && await reader.exists(candidate))
return candidate
}
return undefined
}
/** 存在的那几个,各标 names。 */
async function existing(candidates: readonly (string | undefined)[], names: readonly string[], reader: FolderReader): Promise<Entry[]> {
const files = [...new Set(candidates.filter((file): file is string => file !== undefined))]
const present = await Promise.all(files.map(file => reader.exists(file)))
return files.filter((_, i) => present[i]).map(file => ({ path: file, names }))
}
function tomlOf(text: string | undefined): Table {
if (text === undefined)
return {}
try {
const parsed: unknown = parseToml(text)
return isTable(parsed) ? parsed : {}
}
catch {
return {}
}
}
function tableIn(table: Table, key: string): Table {
const value = table[key]
return isTable(value) ? value : {}
}
/** 点分的名字:`agent_cli.main`、`Cli.run`。 */
const DOTTED_NAME = /^[\p{L}_][\p{L}\p{N}_]*(?:\.[\p{L}_][\p{L}\p{N}_]*)*$/u
/**
* 入口写法 `模块.路径:名字`(`agent_cli.main:app`)。名字可以带点(`Cli.run`),预览里标的是文件最外层的那一块,取第一节;
* 后面跟着的 `[extra]` 不管。只写了模块、没有名字的只标文件。
*/
function objectReference(value: unknown): { module: string, name?: string } | undefined {
if (typeof value !== 'string')
return undefined
const [module, object, ...rest] = value.replace(/\[[^\]]*\]\s*$/, '').split(':').map(part => part.trim())
if (rest.length > 0 || !DOTTED_NAME.test(module) || (object !== undefined && !DOTTED_NAME.test(object)))
return undefined
return object === undefined ? { module } : { module, name: object.split('.')[0] }
}
/** 模块对应的文件,按这个顺序找第一个存在的:src 布局的模块文件、平铺的模块文件、src 布局的包、平铺的包。 */
function moduleFiles(module: string): string[] {
const at = module.replaceAll('.', '/')
return [`src/${at}.py`, `${at}.py`, `src/${at}/__init__.py`, `${at}/__init__.py`]
}
/** pyproject.toml 的 `[project.scripts]`、`[project.gui-scripts]`:每条指向的模块文件,标它的名字。 */
async function pythonEntries(manifest: string, reader: FolderReader): Promise<Entry[]> {
const project = tableIn(tomlOf(await reader.read(manifest)), 'project')
const targets = [tableIn(project, 'scripts'), tableIn(project, 'gui-scripts')].flatMap(Object.values).map(objectReference)
const found = await Promise.all(targets.map(async (target) => {
const file = target && await firstExisting(moduleFiles(target.module).map(candidate => inDir(manifest, candidate)), reader)
return file ? [{ path: file, names: target.name === undefined ? [] : [target.name] }] : []
}))
return found.flat()
}
/** Cargo.toml:`src/main.rs`,和 `[[bin]]` 里写了 `path` 的;都从 `main` 读起。`src/lib.rs` 是库,不算。 */
async function cargoEntries(manifest: string, reader: FolderReader): Promise<Entry[]> {
const bins = tomlOf(await reader.read(manifest)).bin
const paths = Array.isArray(bins) ? bins.flatMap(bin => isTable(bin) && typeof bin.path === 'string' ? [bin.path] : []) : []
return existing(['src/main.rs', ...paths].map(file => inDir(manifest, file)), ['main'], reader)
}
/** package.json 的 `bin`:一个文件,或 `{ 命令名: 文件 }`。只标文件。 */
async function packageEntries(manifest: string, reader: FolderReader): Promise<Entry[]> {
const text = await reader.read(manifest)
let bin: unknown
try {
bin = text === undefined ? undefined : (JSON.parse(text) as Table).bin
}
catch {
return []
}
const files = typeof bin === 'string' ? [bin] : isTable(bin) ? Object.values(bin).filter((file): file is string => typeof file === 'string') : []
return existing(files.map(file => inDir(manifest, file)), [], reader)
}
const GO_WORD = /[\p{L}_][\p{L}\p{N}_]*/uy
/** 引号括起来的字符串、字符从 start 起到哪儿结束(反斜杠转义;没收尾的到行末)。 */
function quotedEnd(text: string, start: number): number {
const quote = text[start]
for (let i = start + 1; i < text.length; i++) {
if (text[i] === '\\')
i++
else if (text[i] === quote)
return i + 1
else if (text[i] === '\n')
return i
}
return text.length
}
/** Go 源码里花括号外面的词和左括号,跳过注释、字符串(含反引号的原样字符串)、字符。 */
function goTopLevelTokens(text: string): string[] {
const tokens: string[] = []
let depth = 0
let i = 0
while (i < text.length) {
if (text.startsWith('//', i)) {
const end = text.indexOf('\n', i)
i = end < 0 ? text.length : end
continue
}
if (text.startsWith('/*', i)) {
const end = text.indexOf('*/', i + 2)
i = end < 0 ? text.length : end + 2
continue
}
const char = text[i]
if (char === '`') {
const end = text.indexOf('`', i + 1)
i = end < 0 ? text.length : end + 1
continue
}
if (char === '"' || char === '\'') {
i = quotedEnd(text, i)
continue
}
GO_WORD.lastIndex = i
const word = GO_WORD.exec(text)
if (word) {
if (depth === 0)
tokens.push(word[0])
i += word[0].length
continue
}
if (char === '{')
depth++
else if (char === '}')
depth = Math.max(0, depth - 1)
else if (char === '(' && depth === 0)
tokens.push(char)
i++
}
return tokens
}
/** 文件的包是 `package main`,最外层有 `func main(`(方法、写在字符串和注释里的都不算)。 */
function isGoMain(text: string): boolean {
const tokens = goTopLevelTokens(text)
return tokens[0] === 'package' && tokens[1] === 'main'
&& tokens.some((token, i) => token === 'func' && tokens[i + 1] === 'main' && tokens[i + 2] === '(')
}
/** go.mod 所在各目录里的 Go 源码:每个文件归最近的那个 go.mod(别的 go.mod 的子目录归别的模块),`package main` 加 `func main` 的从 `main` 读起。 */
async function goEntries(modules: ReadonlySet<string>, sources: readonly string[], reader: FolderReader): Promise<Entry[]> {
const ownerOf = (file: string): string | undefined => {
for (let dir = path.posix.dirname(file); ; dir = path.posix.dirname(dir)) {
if (modules.has(dir))
return dir
if (dir === '.')
return undefined
}
}
const owned = sources.filter(file => ownerOf(file) !== undefined)
const texts = await Promise.all(owned.map(file => reader.read(file)))
return owned.filter((_, i) => isGoMain(texts[i] ?? '')).map(file => ({ path: file, names: ['main'] }))
}
/**
* 各项目自己写明的启动项,不靠文件名、函数名猜:
* - pyproject.toml 的 `[project.scripts]`、`[project.gui-scripts]`;
* - Cargo.toml 所在目录的 `src/main.rs`,`[[bin]]` 的 `path`;
* - go.mod 的模块里 `package main` 又有 `func main` 的文件;
* - package.json 的 `bin`。
* 每个清单各管各的项目(相对它所在的目录),一个工作区文件夹里可以有很多个。指向的文件不存在的不标。
*
* @param paths 工作区文件夹里匹配 ENTRY_SOURCES 的文件(相对工作区文件夹,`/` 分隔);在 SKIPPED_DIRS 里的不看
* @param reader 读清单、Go 源码,问入口文件在不在
*/
export async function findEntryPoints(paths: readonly string[], reader: FolderReader): Promise<EntryPoints> {
const kept = paths.filter(file => !isSkipped(file))
const named = (name: string): string[] => kept.filter(file => path.posix.basename(file) === name)
const modules = new Set(named('go.mod').map(file => path.posix.dirname(file)))
const found = await Promise.all([
...named('pyproject.toml').map(file => pythonEntries(file, reader)),
...named('Cargo.toml').map(file => cargoEntries(file, reader)),
...named('package.json').map(file => packageEntries(file, reader)),
goEntries(modules, kept.filter(file => file.endsWith('.go')), reader),
])
const entries = new Map<string, string[]>()
for (const { path: file, names } of found.flat()) {
const known = entries.get(file) ?? []
entries.set(file, [...known, ...names.filter(name => !known.includes(name))])
}
return entries
}
@@ -0,0 +1,113 @@
import type { Disposable, WorkspaceFolder } from 'vscode'
import type { RowEntries } from '../explorer/rowDecorator'
import type { EntryPoints } from './entryPoints'
import { EventEmitter, RelativePattern, Uri, workspace } from 'vscode'
import { logger } from '../../logging/logger'
import { locate } from '../folderStore'
import { toPathKey } from '../pathMap'
import { folderReader } from '../workspaceFiles'
import { ENTRY_SOURCES, findEntryPoints, isSkipped, SKIPPED_GLOB } from './entryPoints'
/** 连着的文件增删(装依赖、切分支)合并成一次重扫。 */
const RESCAN_DELAY_MS = 500
/**
* 各工作区文件夹里项目写明的入口(见 findEntryPoints)。打开时扫一遍;之后清单、Go 源码改了,或文件增删了(入口文件出现、消失),
* 等一会儿重扫那个工作区文件夹。入口变了的文件报出去:侧栏重画那几行,开着的预览重新标名字。
*/
export class WorkspaceEntries implements RowEntries, Disposable {
/** 工作区文件夹 → 它里面的入口 */
private readonly found = new Map<string, EntryPoints>()
private readonly timers = new Map<string, ReturnType<typeof setTimeout>>()
/** 每个工作区文件夹最近一次扫描的序号:先发出的扫描后回来时不盖掉新的 */
private readonly scans = new Map<string, number>()
private readonly changed = new EventEmitter<readonly Uri[]>()
/** 入口变了的那些文件 */
readonly onDidChange = this.changed.event
private readonly disposables: Disposable[]
constructor() {
const sources = workspace.createFileSystemWatcher(ENTRY_SOURCES, true, false, true)
const files = workspace.createFileSystemWatcher('**/*', false, true, false)
this.disposables = [
this.changed,
sources,
files,
sources.onDidChange(uri => this.schedule(uri)),
files.onDidCreate(uri => this.schedule(uri)),
files.onDidDelete(uri => this.schedule(uri)),
workspace.onDidChangeWorkspaceFolders((e) => {
for (const folder of e.removed)
this.forget(folder)
for (const folder of e.added)
void this.scan(folder)
}),
]
for (const folder of workspace.workspaceFolders ?? [])
void this.scan(folder)
}
/** 是项目入口时,预览里要标的名字(可能是空的:只标文件);不是入口时 undefined。 */
namesOf(uri: Uri): readonly string[] | undefined {
const at = locate(uri)
return at && this.found.get(at.folder.uri.toString())?.get(at.key)
}
dispose(): void {
for (const timer of this.timers.values())
clearTimeout(timer)
for (const disposable of this.disposables)
disposable.dispose()
}
private schedule(uri: Uri): void {
const at = locate(uri)
if (!at || isSkipped(at.key))
return
const key = at.folder.uri.toString()
clearTimeout(this.timers.get(key))
this.timers.set(key, setTimeout(() => {
this.timers.delete(key)
void this.scan(at.folder)
}, RESCAN_DELAY_MS))
}
private async scan(folder: WorkspaceFolder): Promise<void> {
const key = folder.uri.toString()
const scan = (this.scans.get(key) ?? 0) + 1
this.scans.set(key, scan)
try {
const uris = await workspace.findFiles(new RelativePattern(folder, ENTRY_SOURCES), SKIPPED_GLOB)
const paths = uris.flatMap((uri) => {
const path = toPathKey(folder.uri.path, uri.path)
return path === undefined ? [] : [path]
})
const entries = await findEntryPoints(paths, folderReader(folder))
if (this.scans.get(key) === scan)
this.publish(folder, entries)
}
catch (err) {
logger.add({ level: 'warn', source: 'codeReader', message: `没能找出「${folder.name}」里的项目入口:${err instanceof Error ? err.message : String(err)}` })
}
}
private forget(folder: WorkspaceFolder): void {
const key = folder.uri.toString()
clearTimeout(this.timers.get(key))
this.timers.delete(key)
this.scans.delete(key)
this.publish(folder, new Map())
this.found.delete(key)
}
/** 换上新扫出来的入口,报出入口、名字变了的文件。 */
private publish(folder: WorkspaceFolder, entries: EntryPoints): void {
const key = folder.uri.toString()
const before: EntryPoints = this.found.get(key) ?? new Map()
this.found.set(key, entries)
const changed = [...new Set([...before.keys(), ...entries.keys()])]
.filter(path => before.get(path)?.join('\n') !== entries.get(path)?.join('\n'))
if (changed.length > 0)
this.changed.fire(changed.map(path => Uri.joinPath(folder.uri, path)))
}
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { escapeMarkdown, rowDecoration } from './rowDecoration'
describe('树上的一行', () => {
it('没有注释、不是入口的行保持原样', () => {
expect(rowDecoration('file', 'src/a.ts', undefined, undefined)).toBeUndefined()
})
it('有注释:名字后面灰字是注释,contextValue 加 noted,提示里粗体路径下一行注释全文', () => {
expect(rowDecoration('file', 'campaign/order_bonus.py', '下单返利:订单完成后按活动规则发积分', undefined)).toEqual({
description: '下单返利:订单完成后按活动规则发积分',
tooltip: '**campaign\\/order\\_bonus\\.py** \n下单返利:订单完成后按活动规则发积分',
contextValue: 'file noted',
})
expect(rowDecoration('folder', 'campaign', '营销活动', undefined)?.contextValue).toBe('folder noted')
})
it('入口、没注释:灰字是「入口」,提示里写从哪个名字开始读,contextValue 不变', () => {
expect(rowDecoration('file', 'src/agent_cli/main.py', undefined, ['app', 'dev'])).toEqual({
description: '入口',
tooltip: '**src\\/agent\\_cli\\/main\\.py** \n入口,从 `app`、`dev` 开始读',
contextValue: 'file',
})
expect(rowDecoration('file', 'bin/cli.js', undefined, [])?.tooltip).toBe('**bin\\/cli\\.js** \n入口')
})
it('入口、有注释:灰字是「注释 · 入口」,注释不被盖掉,提示里注释下面再写入口', () => {
expect(rowDecoration('file', 'src/main.rs', '服务启动', ['main'])).toEqual({
description: '服务启动 · 入口',
tooltip: '**src\\/main\\.rs** \n服务启动 \n入口,从 `main` 开始读',
contextValue: 'file noted',
})
})
it('注释里的 Markdown 符号原样显示', () => {
expect(escapeMarkdown('**别加粗** <b>x</b> [链接](command:x) `代码` # 标题 $(icon)'))
.toBe('\\*\\*别加粗\\*\\* \\<b\\>x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)')
})
})
@@ -0,0 +1,36 @@
/** 侧栏一行名字后面的灰字和悬停提示:文件注释、项目入口。 */
export interface RowDecoration {
/** 名字后面的灰字:注释,是入口时后面接「 · 入口」,没注释时就是「入口」;VS Code 放不下时自己截断 */
description: string
/** 悬停提示(Markdown):第一行粗体的相对路径,下面是注释全文,是入口时再一行写明 */
tooltip: string
/** 有注释时末尾加上 ` noted`:菜单的 when 子句靠它在「添加注释」和「编辑注释」之间换;是不是入口不影响 */
contextValue: string
}
/** 每个 ASCII 标点前加反斜杠(CommonMark 允许任何 ASCII 标点这样转义),注释里的 `*`、`_`、`<`、`[` 等原样显示。 */
export function escapeMarkdown(text: string): string {
return text.replace(/[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]/g, '\\$&')
}
/**
* @param contextValue 这一行原本的 contextValue(`file` / `folder`)
* @param key 相对工作区文件夹的路径
* @param note 文件注释
* @param entry 是项目写明的入口时,预览里要标的名字(package.json 的 `bin` 没有名字,是空的);不是入口时 undefined
* @returns 没有注释、也不是入口时 undefined,这一行保持原样
*/
export function rowDecoration(contextValue: string, key: string, note: string | undefined, entry: readonly string[] | undefined): RowDecoration | undefined {
if (note === undefined && entry === undefined)
return undefined
const lines = [`**${escapeMarkdown(key)}**`]
if (note !== undefined)
lines.push(escapeMarkdown(note))
if (entry !== undefined)
lines.push(entry.length > 0 ? `入口,从 ${entry.map(name => `\`${name}\``).join('、')} 开始读` : '入口')
return {
description: note === undefined ? '入口' : entry === undefined ? note : `${note} · 入口`,
tooltip: lines.join(' \n'),
contextValue: note === undefined ? contextValue : `${contextValue} noted`,
}
}
@@ -0,0 +1,40 @@
import type { Event, TreeItem, Uri } from 'vscode'
import type { FileNode, FileTreeDecorator } from './fileTree'
import { Disposable, MarkdownString } from 'vscode'
import { locate } from '../folderStore'
import { rowDecoration } from './rowDecoration'
/** 各行的文件注释(见 notes/)。 */
export interface RowNotes {
readonly onDidChange: Event<readonly Uri[]>
noteOf: (node: FileNode) => string | undefined
}
/** 哪些文件是项目写明的入口(见 entries/)。 */
export interface RowEntries {
readonly onDidChange: Event<readonly Uri[]>
namesOf: (uri: Uri) => readonly string[] | undefined
}
/** 侧栏一行名字后面的灰字和悬停提示:文件注释,后面接项目入口(见 rowDecoration);工作区外面的行(审查的工作副本)不加。 */
export class RowDecorator implements FileTreeDecorator {
constructor(private readonly notes: RowNotes, private readonly entries: RowEntries) {}
/** 注释变了、入口变了的那些行 */
readonly onDidChange: Event<readonly Uri[]> = (listener, thisArgs, disposables) => {
const both = Disposable.from(this.notes.onDidChange(listener, thisArgs), this.entries.onDidChange(listener, thisArgs))
disposables?.push(both)
return both
}
decorate(item: TreeItem, node: FileNode): void {
const at = locate(node.uri)
const entry = node.isDirectory ? undefined : this.entries.namesOf(node.uri)
const decoration = at && rowDecoration(item.contextValue ?? '', at.key, this.notes.noteOf(node), entry)
if (!decoration)
return
item.description = decoration.description
item.tooltip = new MarkdownString(decoration.tooltip)
item.contextValue = decoration.contextValue
}
}
@@ -1,10 +1,10 @@
import { readFileSync } from 'node:fs'
import * as path from 'node:path'
import { describe, expect, it } from 'vitest'
import { noteDecoration } from '../notes/noteItem'
import { UNSTAGED_MARKER } from '../uncommitted/uncommittedFiles'
import { COPY_COMMANDS } from './copyText'
import { entryContext, GROUP_CONTEXT } from './fileEntries'
import { rowDecoration } from './rowDecoration'
interface MenuItem { command?: string, submenu?: string, when: string, group?: string }
interface Manifest {
@@ -28,7 +28,7 @@ const root = entryContext({ isDirectory: true, isRoot: true })
const unstaged = entryContext({ isDirectory: false, isRoot: false }, UNSTAGED_MARKER)
const STAGE = 'superpowers.codeReader.stage'
const ASK = 'superpowers.codeReader.askPath'
const noted = (context: string): string => noteDecoration(context, 'x', '注释')!.contextValue
const noted = (context: string): string => rowDecoration(context, 'x', '注释', undefined)!.contextValue
const orderOf = (item: MenuItem): number => Number(item.group?.split('@')[1] ?? 0)
+6 -2
View File
@@ -12,9 +12,11 @@ import { NEXT_OCCURRENCE, PREVIOUS_OCCURRENCE } from './editor/occurrenceNavigat
import { PATH_PREVIEW_SCHEME } from './editor/pathPreview'
import { PREVIEW_VIEW_TYPE, PreviewEditorProvider } from './editor/previewEditor'
import { showPreview, showSource } from './editor/switchEditor'
import { WorkspaceEntries } from './entries/workspaceEntries'
import { registerCopyCommands } from './explorer/copyCommands'
import { FileTreeProvider } from './explorer/fileTree'
import { registerPathAsk } from './explorer/pathAskPanel'
import { RowDecorator } from './explorer/rowDecorator'
import { TreeFilters } from './explorer/treeFilters'
import { createBashAdapter } from './languages/bash'
import { createDockerfileAdapter } from './languages/dockerfile'
@@ -97,17 +99,19 @@ export function registerCodeReader(context: ExtensionContext): CodeReaderApi {
return adapter ? indexedNames((index, key) => indexes.find(index, key), adapter, name) : []
}
const notes = new FileNotes()
const files = new FileTreeProvider(OPEN_COMMAND, notes)
const entries = new WorkspaceEntries()
const files = new FileTreeProvider(OPEN_COMMAND, new RowDecorator(notes, entries))
const view = window.createTreeView(FILES_VIEW, { treeDataProvider: files, showCollapseAll: true })
const filters = new TreeFilters(files, view, context.workspaceState)
const review = new ReviewMode(context, FILES_VIEW, view, files, filters)
const uncommitted = new UncommittedMode(context, FILES_VIEW, view, files, filters)
// 侧栏开着的筛选定预览对比什么:未提交的更改对比上一次提交,审查工单对比 PR 的目标分支
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters)
const previews = new PreviewEditorProvider(context.extensionUri, registry, runtime, new BlockFilter(context.workspaceState), resolve, qa, namesIn, (index, key) => indexes.find(index, key), filters, entries)
context.subscriptions.push(
qa,
notes,
entries,
indexes,
registerNoteCommands(notes),
registerCopyCommands(),
+4 -15
View File
@@ -1,8 +1,7 @@
import type { Disposable, TreeItem } from 'vscode'
import type { FileNode, FileTreeDecorator } from '../explorer/fileTree'
import { MarkdownString } from 'vscode'
import type { Disposable } from 'vscode'
import type { FileNode } from '../explorer/fileTree'
import type { RowNotes } from '../explorer/rowDecorator'
import { FolderStore, locate } from '../folderStore'
import { noteDecoration } from './noteItem'
import { withNote } from './noteMap'
import { NOTES_FILE } from './store'
@@ -12,7 +11,7 @@ import { NOTES_FILE } from './store'
* 注释记在文件所在的工作区文件夹里,和树从哪个根摆下来(工作区文件夹、仓库根)无关;工作区外面的文件(审查的工作副本)不记。
* 在 VS Code 里改名、挪位置,注释跟着走;删掉就一起删。外部改了文件(git pull、手改)按文件重读(见 FolderStore)。
*/
export class FileNotes implements FileTreeDecorator, Disposable {
export class FileNotes implements RowNotes, Disposable {
private readonly store = new FolderStore(NOTES_FILE)
/** 注释变了的那些行 */
readonly onDidChange = this.store.onDidChange
@@ -23,16 +22,6 @@ export class FileNotes implements FileTreeDecorator, Disposable {
return at && this.store.get(at.folder, at.key)
}
decorate(item: TreeItem, node: FileNode): void {
const at = locate(node.uri)
const decoration = at && noteDecoration(item.contextValue ?? '', at.key, this.store.get(at.folder, at.key))
if (!decoration)
return
item.description = decoration.description
item.tooltip = new MarkdownString(decoration.tooltip)
item.contextValue = decoration.contextValue
}
/** 保存注释:去掉首尾空白,空的就删掉这条。 */
async save(node: FileNode, text: string): Promise<void> {
const at = locate(node.uri)
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest'
import { escapeMarkdown, noteDecoration } from './noteItem'
describe('树上的一行', () => {
it('没有注释的行保持原样', () => {
expect(noteDecoration('file', 'src/a.ts', undefined)).toBeUndefined()
})
it('有注释:名字后面灰字是注释,contextValue 加 noted,提示里粗体路径下一行注释全文', () => {
expect(noteDecoration('file', 'campaign/order_bonus.py', '下单返利:订单完成后按活动规则发积分')).toEqual({
description: '下单返利:订单完成后按活动规则发积分',
tooltip: '**campaign\\/order\\_bonus\\.py** \n下单返利:订单完成后按活动规则发积分',
contextValue: 'file noted',
})
expect(noteDecoration('folder', 'campaign', '营销活动')?.contextValue).toBe('folder noted')
})
it('注释里的 Markdown 符号原样显示', () => {
expect(escapeMarkdown('**别加粗** <b>x</b> [链接](command:x) `代码` # 标题 $(icon)'))
.toBe('\\*\\*别加粗\\*\\* \\<b\\>x\\<\\/b\\> \\[链接\\]\\(command\\:x\\) \\`代码\\` \\# 标题 \\$\\(icon\\)')
})
})
-29
View File
@@ -1,29 +0,0 @@
/** 树上有注释的一行怎么显示。 */
export interface NoteDecoration {
/** 名字后面的灰字,VS Code 放不下时自己截断 */
description: string
/** 悬停提示(Markdown):第一行粗体的相对路径,下一行注释全文 */
tooltip: string
/** 末尾加上 ` noted`:菜单的 when 子句靠它在「添加注释」和「编辑注释」之间换 */
contextValue: string
}
/** 每个 ASCII 标点前加反斜杠(CommonMark 允许任何 ASCII 标点这样转义),注释里的 `*`、`_`、`<`、`[` 等原样显示。 */
export function escapeMarkdown(text: string): string {
return text.replace(/[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]/g, '\\$&')
}
/**
* @param contextValue 这一行原本的 contextValue(`file` / `folder`)
* @param key 相对工作区文件夹的路径
* @returns 没有注释时 undefined,这一行保持原样
*/
export function noteDecoration(contextValue: string, key: string, note: string | undefined): NoteDecoration | undefined {
if (note === undefined)
return undefined
return {
description: note,
tooltip: `**${escapeMarkdown(key)}** \n${escapeMarkdown(note)}`,
contextValue: `${contextValue} noted`,
}
}
+8 -4
View File
@@ -38,14 +38,18 @@ function workspaceFile(uri: Uri): { file: WorkspaceFile, folder: WorkspaceFolder
return { folder, file: { uri: uri.toString(), folder: folder.uri.toString(), path: path.posix.relative(folder.uri.path, uri.path) } }
}
/** 这个工作区文件夹里还要跳过的(嵌套的 checkout、忽略文件忽略的),见 folderScope;忽略文件按 search.useIgnoreFiles。 */
function scopeOf(folder: WorkspaceFolder): ReturnType<typeof folderScope> {
/** 读这个工作区文件夹里的文件(路径相对它、`/` 分隔)。 */
export function folderReader(folder: WorkspaceFolder): FolderReader {
const at = (path: string): Uri => Uri.joinPath(folder.uri, path)
const reader: FolderReader = {
return {
exists: path => Promise.resolve(workspace.fs.stat(at(path))).then(() => true, () => false),
read: path => Promise.resolve(workspace.fs.readFile(at(path))).then(bytes => new TextDecoder('utf-8').decode(bytes), () => undefined),
}
return folderScope(reader, workspace.getConfiguration('search', folder.uri).get<boolean>('useIgnoreFiles', true))
}
/** 这个工作区文件夹里还要跳过的(嵌套的 checkout、忽略文件忽略的),见 folderScope;忽略文件按 search.useIgnoreFiles。 */
function scopeOf(folder: WorkspaceFolder): ReturnType<typeof folderScope> {
return folderScope(folderReader(folder), workspace.getConfiguration('search', folder.uri).get<boolean>('useIgnoreFiles', true))
}
/**