✨ feat(vscode): 代码阅读支持 Shell 脚本(bash / sh / zsh):解释器、选项每个开关一行、变量展开读成中文、分情况表、函数按位置的参数 / 输出 / 返回、常用命令按命令规则读成一句、重定向、条件、遍历、管道;同一文件里的函数和变量能跳转、高亮
This commit is contained in:
1 parent
c577f38bee
commit
e96694317d
12 files changed
+2559
-3
No files matched your search
@@ -0,0 +1,193 @@
|
||||
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: [2, 13], kind: 'comment' },
|
||||
{ at: 14, kind: 'options' },
|
||||
{ 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: 72, kind: 'comment' },
|
||||
{ at: 73, kind: 'command' },
|
||||
{ at: [75, 77], kind: 'comment' },
|
||||
{ at: 78, kind: 'case', kids: [
|
||||
{ at: 79, kind: 'case', kids: [{ at: 80, kind: 'variable' }, { at: [81, 82], kind: 'command' }, { at: 83, kind: 'comment' }, { at: 84, kind: 'command' }] },
|
||||
{ at: 86, kind: 'case', kids: [{ at: [87, 88], kind: 'comment' }, { at: 89, kind: 'variable' }, { at: 90, kind: 'command' }, { at: 91, kind: 'command' }] },
|
||||
] },
|
||||
{ at: [95, 97], kind: 'comment' },
|
||||
{ at: 98, kind: 'if', kids: [{ at: 99, kind: 'command' }] },
|
||||
{ at: [102, 104], kind: 'comment' },
|
||||
{ at: 105, kind: 'if', kids: [
|
||||
{ at: 106, kind: 'variable' },
|
||||
{ at: [107, 108], kind: 'comment' },
|
||||
{ at: 109, kind: 'loop', kids: [{ at: 110, kind: 'if' }, { at: [111, 114], kind: 'case' }] },
|
||||
{ at: 116, kind: 'comment' },
|
||||
{ at: 117, kind: 'variable' },
|
||||
{ at: 118, kind: 'variable' },
|
||||
{ at: 119, kind: 'command' },
|
||||
{ at: 120, kind: 'command' },
|
||||
{ at: 121, kind: 'command' },
|
||||
{ at: 122, kind: 'comment' },
|
||||
{ at: 123, kind: 'pipeline', 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 命令、名字', () => {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user