Files
superwork/vscode/src/codeReader/languages/bash.test.ts
T
cruldra 582ac4c987 ✨ feat(vscode): 代码阅读的注释挂到它讲的代码块上成「注释」一行,重写注释改用技能一句话加结构化结果
- 普通注释不再单独成卡片:同一层挂到后面最近的块(隔空行也算),后面没有挂到前面那块,整层没代码挂到父块;只有文件开头隔着空行的注释、模块 docstring、//!、@file 单独成「文件开头的说明」
- 各语言(Python、TypeScript、Rust、Shell、Dockerfile)建好树后统一调 attachComments;块的编辑范围连着挂上的注释,差异、跳转、钉住卡片都按它
- 宿主统一给每张卡片(语言卡片、翻译插件的属性表、库规则卡片、一行译文的语句卡片)插「注释」一行,排在名称、说明后面
- TypeScript 的 JSDoc 也能当常量、变量的说明;sqlx、React 不再单独处理前面的注释
- 「重写注释」每块都有:提示只发 /ecc:writing-code-comments 路径:起-止,模型只读、按 --json-schema 交 body,没交结构化正文就失败不写;扩展换掉块前面的注释或在块上面新写一段
2026-10-08 07:51:21 +08:00

253 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Outline } from '../testSupport'
import { describe, expect, it } from 'vitest'
import { factsByLine, nodeFacts, nodesToText, outline, readFixture, runtime, WASM_DIR, withBlocks } from '../testSupport'
import { createBashAdapter } from './bash'
const adapter = createBashAdapter(WASM_DIR)
const FETCH = readFixture('fetchRuntime.sh')
function tree(source: string): Promise<Outline[]> {
return withBlocks(adapter, 'shellscript', source, outline)
}
function facts(source: string): Promise<Record<number, unknown>> {
return withBlocks(adapter, 'shellscript', source, factsByLine)
}
/** luffy 桌面端 fetch-runtime.sh 的代码块树(同原型 SHELL_BLOCKS,原型略去的 95-100、116-121 行也在)。 */
const FETCH_TREE: Outline[] = [
{ at: 1, kind: 'interpreter' },
{ at: 14, kind: 'options', notes: [[2, 13]] },
{ at: 16, kind: 'variable' },
{ at: 17, kind: 'variable' },
{ at: 18, kind: 'variable' },
{ at: 19, kind: 'variable' },
{ at: 21, kind: 'const' },
{ at: 22, kind: 'const' },
{ at: [24, 39], kind: 'case' },
{ at: 40, kind: 'variable' },
{ at: 42, kind: 'function', kids: [{ at: 43, kind: 'if' }] },
{ at: [46, 47], kind: 'function', kids: [
{ at: 48, kind: 'variable' },
{ at: 49, kind: 'variable' },
{ at: 50, kind: 'command' },
{ at: 51, kind: 'if', kids: [{ at: 52, kind: 'command' }, { at: 53, kind: 'command' }, { at: 54, kind: 'command' }] },
{ at: 56, kind: 'if', kids: [{ at: 57, kind: 'command' }, { at: 58, kind: 'command' }] },
{ at: 60, kind: 'command' },
] },
{ at: 63, kind: 'variable' },
{ at: 64, kind: 'variable' },
{ at: 66, kind: 'command' },
{ at: 67, kind: 'command' },
{ at: [68, 71], kind: 'case' },
{ at: 73, kind: 'command', notes: [72] },
{ at: 78, kind: 'case', notes: [[75, 77]], kids: [
{ at: 79, kind: 'case', kids: [{ at: 80, kind: 'variable' }, { at: [81, 82], kind: 'command' }, { at: 84, kind: 'command', notes: [83] }] },
{ at: 86, kind: 'case', kids: [{ at: 89, kind: 'variable', notes: [[87, 88]] }, { at: 90, kind: 'command' }, { at: 91, kind: 'command' }] },
] },
{ at: 98, kind: 'if', notes: [[95, 97]], kids: [{ at: 99, kind: 'command' }] },
{ at: 105, kind: 'if', notes: [[102, 104]], kids: [
{ at: 106, kind: 'variable' },
{ at: 109, kind: 'loop', notes: [[107, 108]], kids: [{ at: 110, kind: 'if' }, { at: [111, 114], kind: 'case' }] },
{ at: 117, kind: 'variable', notes: [116] },
{ at: 118, kind: 'variable' },
{ at: 119, kind: 'command' },
{ at: 120, kind: 'command' },
{ at: 121, kind: 'command' },
{ at: 123, kind: 'pipeline', notes: [122], kids: [{ at: 124, kind: 'command' }, { at: 125, kind: 'command' }] },
] },
{ at: 129, kind: 'command' },
{ at: 130, kind: 'command' },
]
describe('shell 代码块树', () => {
it('fetch-runtime.sh:解释器、选项、变量 / 常量、分情况(短的整条一块,长的每个情况一块子块)、函数(紧贴的注释算进头部)、命令、管道后面的循环主体是子块;注释挂到后面那块上', async () => {
expect(await tree(FETCH)).toEqual(FETCH_TREE)
})
it('elif / else 展平成紧跟 if 的兄弟块;while、for 的主体是子块;一串全是条件、条件后面跟一句的算条件,别的一串命令算命令', async () => {
const source = [
'if [ -z "$a" ]; then',
' echo a',
'elif [ -n "$b" ]; then',
' echo b',
'else',
' echo c',
'fi',
'while true; do',
' sleep 1',
'done',
'[ -d "$x" ]',
'[ -f "$x" ] && rm "$x"',
'mkdir -p "$x" && cd "$x"',
'return 1',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'if', kids: [{ at: 2, kind: 'command' }] },
{ at: 3, kind: 'else', kids: [{ at: 4, kind: 'command' }] },
{ at: 5, kind: 'else', kids: [{ at: 6, kind: 'command' }] },
{ at: 8, kind: 'loop', kids: [{ at: 9, kind: 'command' }] },
{ at: 11, kind: 'if' },
{ at: 12, kind: 'if' },
{ at: 13, kind: 'command' },
{ at: 14, kind: 'return' },
])
})
})
describe('shell 属性表信息(facts)', () => {
it('解释器:env 按 PATH 找,写死的路径照写;选项:连写的开关拆开,-o 跟长名字,+ 是关掉;set -- 不是选项', async () => {
expect((await facts(FETCH))[1]).toEqual({ program: 'bash', lookup: 'env', command: '/usr/bin/env bash' })
expect((await facts('#!/bin/sh -e\n'))[1]).toEqual({ program: 'sh', lookup: 'path', command: '/bin/sh -e' })
expect((await facts(FETCH))[14]).toEqual({ switches: [
{ id: 'errexit', on: true, flag: '-e' },
{ id: 'nounset', on: true, flag: '-u' },
{ id: 'pipefail', on: true, flag: '-o pipefail' },
] })
expect((await facts('set +x -o nounset\n'))[1]).toEqual({ switches: [{ id: 'xtrace', on: false, flag: '+x' }, { id: 'nounset', on: true, flag: '-o nounset' }] })
expect(await tree('set -- a b\n')).toEqual([{ at: 1, kind: 'command' }])
})
it('变量:local 一句几个是一块,没值的也算;readonly、顶层全大写写死的值是常量,值里有展开的不是', async () => {
const byLine = await facts(FETCH)
expect(byLine[48]).toEqual({ declarators: [{ name: 'url', value: '"$1"' }, { name: 'sha', value: '"$2"' }, { name: 'out' }] })
expect(byLine[21]).toEqual({ declarators: [{ name: 'PY_RELEASE', value: '20260924' }] })
expect(await tree('readonly n=1\nPY_URL="$x/y"\nx=1\n')).toEqual([{ at: 1, kind: 'const' }, { at: 2, kind: 'variable' }, { at: 3, kind: 'variable' }])
})
it('函数:按位置取的参数(local x="$1" 起的名字)、打印到输出的几句(重定向到错误输出的不算)、输出被 $(…) 拿去用、写了的 return', async () => {
const byLine = await facts(FETCH)
expect(byLine[42]).toMatchObject({ name: 'sha_ok', positional: [{ position: 1 }, { position: 2 }], prints: [], captured: false, returns: [] })
expect(byLine[46]).toMatchObject({
name: 'fetch',
doc: '# 下载 (已缓存且校验通过就不重下), 回显本地文件路径',
positional: [{ position: 1, name: 'url' }, { position: 2, name: 'sha' }],
prints: ['echo "$out"'],
captured: true,
returns: [],
})
expect((await facts('check() {\n [ -f "$1" ] || return 1\n return 0\n}\n'))[1]).toMatchObject({ returns: ['return 1', 'return 0'] })
})
})
describe('shell 被别的语言嵌着当步骤执行(Dockerfile 的 RUN)', () => {
const steps = async (source: string): Promise<{ blocks: Outline[], chained: boolean, headers: string[] } | undefined> => {
const parsed = await runtime.parse(adapter, 'shellscript', source)
try {
const found = adapter.steps?.(parsed.rootNode, source, 'shellscript')
return found && { blocks: outline(found.blocks), chained: found.chained, headers: found.blocks.map(block => source.slice(block.header.startIndex, block.header.endIndex)) }
}
finally {
parsed.delete()
}
}
it('只用 && 连着的一串拆成一条一步(一步失败就停);整串带着的重定向只算在最后一条上,它的头部延到重定向结尾', async () => {
expect(await steps('apt-get update && echo hi > /f && rm -rf /x')).toEqual({
blocks: [{ at: 1, kind: 'command' }, { at: 1, kind: 'command' }, { at: 1, kind: 'command' }],
chained: true,
headers: ['apt-get update', 'echo hi > /f', 'rm -rf /x'],
})
})
it('条件串、有 || 的串、几句分开写的照 toBlocks 一句一步,不算一步失败就停', async () => {
expect(await steps('[ -f x ] && rm x')).toMatchObject({ blocks: [{ at: 1, kind: 'if' }], chained: false })
expect(await steps('a && b || c')).toMatchObject({ blocks: [{ at: 1, kind: 'command' }], chained: false })
expect(await steps('a\nb')).toMatchObject({ blocks: [{ at: 1, kind: 'command' }, { at: 2, kind: 'command' }], chained: false })
})
})
describe('shell 命令、名字', () => {
const commands = (source: string): Promise<unknown[]> => nodeFacts(adapter, 'shellscript', source, node => adapter.command?.(node))
it('命令:各个词连写死的文字(去掉引号、转义),有展开的没有;重定向读成输出 / 错误输出写到、追加到、并进另一路、从文件读', async () => {
const [cut] = await commands('cut -d\' \' -f1 "$x" a\\ b')
expect(nodesToText(cut)).toMatchObject({
name: { node: 'cut', literal: 'cut' },
arguments: [{ node: '-d\' \'', literal: '-d ' }, { node: '-f1', literal: '-f1' }, { node: '"$x"' }, { node: 'a\\ b', literal: 'a b' }],
redirects: [],
})
// 带重定向的语句和它里面那条命令都认得出,这里只看语句的
const redirected = await nodeFacts(adapter, 'shellscript', 'echo hi >&2\ncmd 2>/dev/null >> log < in\ncmd &> out 2>&1\n', node => node.type === 'redirected_statement' ? adapter.command?.(node) : undefined)
expect(redirected.map(facts => (facts as { redirects: unknown }).redirects)).toEqual([
[{ kind: 'merge', stream: 'output', into: 'error' }],
[{ kind: 'write', stream: 'error', target: '/dev/null' }, { kind: 'append', stream: 'output', target: 'log' }, { kind: 'read', source: 'in' }],
[{ kind: 'write', stream: 'both', target: 'out' }, { kind: 'merge', stream: 'error', into: 'output' }],
])
})
it('同一文件里的名字:函数的定义和调用,变量的赋值、local、for、read 和展开;第几个参数不算', async () => {
const source = 'fetch() {\n local url="$1" out\n out="$url"\n}\nfor f in a; do read -r line; echo "$f $line"; done\nx="$(fetch "$f")"\n'
const parsed = await runtime.parse(adapter, 'shellscript', source)
const symbols = adapter.symbols?.(parsed.rootNode, source, 'shellscript') ?? []
parsed.delete()
const at = (text: string, from = 0): number => source.indexOf(text, from)
const byText = (offset: number): { definitions: string[], occurrences: string[] } | undefined => {
const symbol = symbols.find(entry => entry.occurrences.some(range => range.startIndex === offset))
return symbol && {
definitions: symbol.definitions.map(range => `${source.slice(range.startIndex, range.endIndex)}@${range.startIndex}`),
occurrences: symbol.occurrences.map(range => `${source.slice(range.startIndex, range.endIndex)}@${range.startIndex}`),
}
}
expect(byText(at('fetch'))).toEqual({ definitions: [`fetch@${at('fetch')}`], occurrences: [`fetch@${at('fetch')}`, `fetch@${at('fetch "$f"')}`] })
expect(byText(at('out'))).toEqual({
definitions: [`out@${at('out')}`, `out@${at('out=')}`],
occurrences: [`out@${at('out')}`, `out@${at('out=')}`],
})
expect(byText(at('line'))?.occurrences).toEqual([`line@${at('line')}`, `line@${at('line"')}`])
expect(symbols.some(entry => entry.occurrences.some(range => source.slice(range.startIndex, range.endIndex) === '1'))).toBe(false)
})
it('中文名:变量赋值、声明行尾的 # @Comment 名字', async () => {
const source = 'dest="$1" # @Comment 安装目录\nlocal n # @Comment 次数\n'
const parsed = await runtime.parse(adapter, 'shellscript', source)
try {
const found = adapter.displayNames?.find(parsed.rootNode, source, 'shellscript')
expect(found?.names.map(({ name, text }) => [source.slice(name.startIndex, name.endIndex), text])).toEqual([['dest', '安装目录'], ['n', '次数']])
}
finally {
parsed.delete()
}
})
})
describe('shell 注释挂到它讲的块上', () => {
it('#! 后面隔着空行的是文件的说明;同一层后面最近的那块(隔着空行也算),和函数的说明并存;主体最后的挂到前面那块;文件最后的挂到最后一块', async () => {
const source = [
'#!/usr/bin/env bash',
'# 文件的说明',
'',
'set -e',
'',
'# 讲 fetch:隔着空行',
'',
'# 下载一个文件',
'fetch() {',
' # 先检查',
' if [ -z "$1" ]; then',
' # 只有注释的分支',
' :',
' fi',
' curl -fsSL "$1"',
' # 函数体最后的注释',
'}',
'',
'# 文件最后的注释',
].join('\n')
expect(await tree(source)).toEqual([
{ at: 1, kind: 'interpreter' },
{ at: 2, kind: 'comment' },
{ at: 4, kind: 'options' },
{ at: [8, 9], kind: 'function', notes: [6, 19], kids: [
{ at: 11, kind: 'if', notes: [10], kids: [{ at: 13, kind: 'command', notes: [12] }] },
{ at: 15, kind: 'command', notes: [16] },
] },
])
expect((await facts(source))[8]).toMatchObject({ doc: '# 下载一个文件' })
})
it('#! 后面紧挨着第一块代码的注释挂到它上面', async () => {
expect(await tree('#!/bin/sh\n# 出错就停\nset -e\n')).toEqual([{ at: 1, kind: 'interpreter' }, { at: 3, kind: 'options', notes: [2] }])
})
})