✨ 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

@@ -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)))
}
}