✨ feat(vscode): 代码阅读支持 Shell 脚本(bash / sh / zsh):解释器、选项每个开关一行、变量展开读成中文、分情况表、函数按位置的参数 / 输出 / 返回、常用命令按命令规则读成一句、重定向、条件、遍历、管道;同一文件里的函数和变量能跳转、高亮

This commit is contained in:
cruldra committed 2026-09-28 00:44:53 +08:00
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()
}
})
})
+584
View File
@@ -0,0 +1,584 @@
import type { BlockShape, CodeBlock, CommandFacts, CommandWord, DeclaratorFacts, FunctionFacts, InterpreterFacts, LanguageAdapter, LocalSymbol, Node, OptionsFacts, PositionalParameter, Redirect, SourceRange, SwitchFacts, TextKind, TokenRole } from '../contract'
import type { CommentRules, ListBlocks } from './shared'
import * as path from 'node:path'
import { bashNameReader } from './bashNames'
import { children, compoundShape, leafBlock, leafShape, listBlocks, named } from './shared'
/** 按习惯当常量用的名字:全大写(可带数字、下划线)。 */
const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/
const POSITIONAL = /^[1-9]$/
/** 流程关键字(按关键字着色)和声明关键字(按声明着色)。 */
const FLOW_KEYWORDS = new Set(['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', 'do', 'done', 'case', 'esac', 'in', 'select'])
const DECLARATION_KEYWORDS = new Set(['local', 'export', 'readonly', 'declare', 'typeset', 'function', 'unset'])
/** 写在 `set` 里的单字母开关 → bash 的长名字(`set -o` 用的那个)。 */
const SHORT_SWITCHES: Record<string, string> = {
a: 'allexport',
b: 'notify',
e: 'errexit',
f: 'noglob',
h: 'hashall',
k: 'keyword',
m: 'monitor',
n: 'noexec',
t: 'onecmd',
u: 'nounset',
v: 'verbose',
x: 'xtrace',
B: 'braceexpand',
C: 'noclobber',
E: 'errtrace',
H: 'histexpand',
P: 'physical',
T: 'functrace',
}
/** 打印到输出的内置命令:函数里没重定向的这几句是函数的输出。 */
const PRINTERS = new Set(['echo', 'printf'])
const LOOPS = new Set(['for_statement', 'c_style_for_statement', 'while_statement'])
/** 分情况里一个情况最多几句、每句一行写完,才整个排成一张表(不拆成子块)。 */
const COMPACT_CASE_STATEMENTS = 3
const SIMPLE_STATEMENTS = new Set(['command', 'redirected_statement', 'variable_assignment', 'declaration_command', 'negated_command'])
/** 不出值的展开:命令替换、变量展开、算术展开、进程替换;值里有它们就不是写死的。 */
const EXPANSIONS = new Set(['simple_expansion', 'expansion', 'command_substitution', 'arithmetic_expansion', 'process_substitution'])
const COMMENTS: CommentRules = {
isLine: () => true,
// 紧贴在函数上面一行的注释是函数的说明
isDoc: (comment) => {
const next = comment.nextSibling
return next?.type === 'function_definition' && next.startPosition.row === comment.endPosition.row + 1
},
}
function statements(items: Node[], afterRow: number, top: boolean): CodeBlock[] {
return listBlocks(items, afterRow, item => statementBlocks(item, top), COMMENTS)
}
const nested: ListBlocks = (items, afterRow) => statements(items, afterRow, false)
/** 节点里(不进嵌套的函数定义)满足 test 的节点,先序。 */
function descendants(node: Node, test: (node: Node) => boolean, out: Node[] = []): Node[] {
for (const child of children(node)) {
if (test(child))
out.push(child)
if (child.type !== 'function_definition')
descendants(child, test, out)
}
return out
}
function commandName(command: Node | null | undefined): string | undefined {
return command?.type === 'command' ? command.childForFieldName('name')?.text : undefined
}
/** 语句本身执行的那条命令:`cmd`,或带重定向的 `cmd >&2` 里的 cmd。 */
function commandOf(statement: Node): Node | undefined {
if (statement.type === 'command')
return statement
const body = statement.type === 'redirected_statement' ? statement.childForFieldName('body') : null
return body?.type === 'command' ? body : undefined
}
// ─── 块 ──────────────────────────────────────────────────────────────────────
function statementBlocks(node: Node, top: boolean): CodeBlock[] {
switch (node.type) {
case 'variable_assignment':
return [variableBlock(node, [node], top, false)]
case 'variable_assignments':
return [variableBlock(node, named(node).filter(child => child.type === 'variable_assignment'), top, false)]
case 'declaration_command':
return [declarationBlock(node, top)]
case 'function_definition':
return [functionBlock(node)]
case 'if_statement':
return ifBlocks(node)
case 'for_statement':
case 'c_style_for_statement':
case 'while_statement':
return [loopBlock('loop', node, node)]
case 'case_statement':
return [caseBlock(node)]
case 'pipeline':
return [pipelineBlock(node)]
case 'redirected_statement': {
const body = node.childForFieldName('body')
return body && body.type !== 'command' ? statementBlocks(body, top) : [commandBlock(node)]
}
case 'command':
return [commandBlock(node)]
case 'negated_command':
case 'test_command':
return [leafBlock(isTest(node) ? 'if' : 'command', node)]
case 'list':
return [leafBlock(conditionList(node) ? 'if' : 'command', node)]
default:
return [leafBlock('statement', node)]
}
}
/** 值里没有展开(变量、命令替换……),是写死的。 */
function isLiteral(value: Node): boolean {
return !EXPANSIONS.has(value.type) && named(value).every(isLiteral)
}
/** 常量:readonly 的,或脚本顶层、名字全大写、值写死的。 */
function variableBlock(node: Node, assignments: Node[], top: boolean, readonly: boolean): CodeBlock {
const declarators = assignments.flatMap((assignment): DeclaratorFacts[] => {
if (assignment.type !== 'variable_assignment')
return [{ name: assignment }]
const name = assignment.childForFieldName('name')
const value = assignment.childForFieldName('value')
return name ? [{ name, ...(value && { value }) }] : []
})
const constant = readonly || (top && declarators.length > 0 && declarators.every(({ name, value }) => CONSTANT_NAME.test(name.text) && value && isLiteral(value)))
return { kind: constant ? 'const' : 'variable', ...leafShape(node), facts: { declarators } }
}
/** local / export / readonly / declare:声明的变量是一块;什么都没声明(`export -f fn`)的当命令。 */
function declarationBlock(node: Node, top: boolean): CodeBlock {
const keyword = node.child(0)?.type
const declared = named(node).filter(child => child.type === 'variable_assignment' || child.type === 'variable_name')
if (declared.length === 0)
return leafBlock('command', node)
const readonly = keyword === 'readonly' || named(node).some(child => child.type === 'word' && /^-\w*r/.test(child.text))
return variableBlock(node, declared, top, readonly)
}
function commandBlock(node: Node): CodeBlock {
const command = commandOf(node)
const name = commandName(command)
if (name === 'set' && command && node.type === 'command') {
const switches = optionSwitches(named(command).filter(child => child.type !== 'command_name'))
if (switches)
return { kind: 'options', ...leafShape(node), facts: { switches } }
}
return leafBlock(name === 'return' ? 'return' : 'command', node)
}
/** `set -euo pipefail`、`set +x`、`set -o nounset` 的各个开关;有不是开关的词(`set -- a b`)时不是。 */
function optionSwitches(words: Node[]): OptionsFacts['switches'] | undefined {
const switches: SwitchFacts[] = []
for (let i = 0; i < words.length; i++) {
const text = words[i].text
const match = /^([-+])([a-z]+)$/i.exec(text)
if (!match)
return undefined
const on = match[1] === '-'
for (const letter of match[2]) {
if (letter === 'o') {
const name = words[++i]?.text
if (!name || !/^[a-z]+$/.test(name))
return undefined
switches.push({ id: name, on, flag: `${match[1]}o ${name}` })
continue
}
switches.push({ id: SHORT_SWITCHES[letter] ?? letter, on, flag: `${match[1]}${letter}` })
}
}
return switches.length > 0 ? switches : undefined
}
/** 条件:`[ … ]`、`[[ … ]]`,前面可以带 `!`。 */
function isTest(node: Node): boolean {
if (node.type === 'negated_command')
return named(node).some(isTest)
return node.type === 'test_command'
}
/** `a && b || c` 里用 && / || 连起来的各句,照源码顺序。 */
function listOperands(node: Node): Node[] {
return node.type === 'list' ? named(node).filter(child => child.type !== 'comment').flatMap(listOperands) : [node]
}
/**
* 当条件读的一串:全是条件(`[ -f x ] && [ … ]`,读成「成立:…」),或前面全是条件、最后跟一句(`[ … ] || continue`、
* `[ -f x ] && rm x`,读成「如果(不是)〔…〕,…」)。
*/
function conditionList(node: Node): boolean {
const operands = listOperands(node)
return operands.slice(0, -1).every(isTest) && (isTest(operands[operands.length - 1]) || operands.length > 1)
}
/** 语句块(`{ … }`、do … done、then 后面)里的各句连同注释:去掉关键字、分号。 */
function bodyItems(nodes: Node[]): Node[] {
return nodes.filter(child => child.isNamed)
}
/** 复合语句块:头部到第一句之前(没有句子时到 fromEnd 为止),子块是各句。 */
function compound(kind: TextKind, outer: Node, items: Node[], fromEnd: number): CodeBlock {
const shape: BlockShape = compoundShape(outer, items[0]?.startIndex ?? fromEnd, items, nested)
return { kind, ...shape }
}
/** 循环:头部到 do 为止,子块是 do … done 里的各句。管道后面跟的 while 也是这样拆,只是块本身是整个管道。 */
function loopBlock(kind: TextKind, outer: Node, loop: Node): CodeBlock {
const body = loop.childForFieldName('body')
const items = body ? bodyItems(children(body)) : []
const open = body?.child(0)
return compound(kind, outer, items, open?.endIndex ?? loop.endIndex)
}
/** if 一块,elif / else 展平成紧跟它的兄弟块(同 Python)。 */
function ifBlocks(node: Node): CodeBlock[] {
const clauses = named(node).filter(child => child.type === 'elif_clause' || child.type === 'else_clause')
return [clauseBlock('if', node), ...clauses.map(clause => clauseBlock('else', clause))]
}
/** if、elif 的主体是 then 后面、下一个 elif / else 之前的各句;else 的主体是 else 后面的各句。 */
function clauseBlock(kind: TextKind, node: Node): CodeBlock {
const all = children(node)
const opener = all.findIndex(child => child.type === (node.type === 'else_clause' ? 'else' : 'then'))
const rest = all.slice(opener + 1)
const end = rest.findIndex(child => child.type === 'elif_clause' || child.type === 'else_clause' || child.type === 'fi')
const items = bodyItems(end < 0 ? rest : rest.slice(0, end))
return compound(kind, node, items, all[opener]?.endIndex ?? node.endIndex)
}
function caseItems(node: Node): Node[] {
return named(node).filter(child => child.type === 'case_item')
}
/** 一个情况里要做的各句(连同注释):`)` 后面的、`;;` 之前的。 */
function caseBody(item: Node): Node[] {
const all = children(item)
const close = all.findIndex(child => child.type === ')')
return bodyItems(all.slice(close + 1))
}
/** 每个情况都只有几句、每句一行写完、没有注释和嵌套的语句块。 */
function compactCase(node: Node): boolean {
return caseItems(node).every((item) => {
const body = caseBody(item)
return body.length <= COMPACT_CASE_STATEMENTS && body.every(statement => SIMPLE_STATEMENTS.has(statement.type)
&& statement.startPosition.row === statement.endPosition.row
&& (statement.type !== 'redirected_statement' || commandOf(statement) !== undefined))
})
}
/** 分情况:几个情况都短时整条语句一块(情况排成表),否则头部到 in 为止、每个情况一块子块。 */
function caseBlock(node: Node): CodeBlock {
if (compactCase(node))
return leafBlock('case', node)
const items = caseItems(node)
const inKeyword = children(node).find(child => child.type === 'in')
const shape = compoundShape(node, items[0]?.startIndex ?? inKeyword?.endIndex ?? node.endIndex, [], nested)
return { kind: 'case', ...shape, children: [...shape.children, ...items.map(branchBlock)] }
}
/** 一个情况:头部是它的模式(到 `)` 为止),子块是要做的各句。 */
function branchBlock(item: Node): CodeBlock {
const body = caseBody(item)
const close = children(item).find(child => child.type === ')')
return compound('case', item, body, close?.endIndex ?? item.endIndex)
}
/** 管道:最后一段是循环(`find … | while read -r x; do … done`)时循环的主体是子块。 */
function pipelineBlock(node: Node): CodeBlock {
const last = named(node).at(-1)
if (last && LOOPS.has(last.type))
return loopBlock('pipeline', node, last)
return leafBlock('pipeline', node)
}
// ─── 函数 ────────────────────────────────────────────────────────────────────
/** `$1`、`"$1"`、`${1}` 这种只是取第几个参数的值:取的是第几个。 */
function positionalValue(value: Node | null): number | undefined {
if (!value)
return undefined
const inner = value.type === 'string' && named(value).length === 1 ? named(value)[0] : value
if (inner.type !== 'simple_expansion' && inner.type !== 'expansion')
return undefined
const expanded = named(inner)
const name = expanded.length === 1 ? expanded[0] : undefined
return name?.type === 'variable_name' && POSITIONAL.test(name.text) ? Number(name.text) : undefined
}
/** 函数里用到的第几个参数,和它们在函数里的名字(`local url="$1"`、`url=$1`)。 */
function positionalParameters(body: Node): PositionalParameter[] {
const used = new Set(descendants(body, node => node.type === 'variable_name' && POSITIONAL.test(node.text)
&& (node.parent?.type === 'simple_expansion' || node.parent?.type === 'expansion')).map(node => Number(node.text)))
const names = new Map<number, Node>()
for (const assignment of descendants(body, node => node.type === 'variable_assignment')) {
const position = positionalValue(assignment.childForFieldName('value'))
const name = assignment.childForFieldName('name')
if (position !== undefined && name && !names.has(position))
names.set(position, name)
}
return [...used].sort((a, b) => a - b).map(position => ({ position, ...(names.has(position) && { name: names.get(position) }) }))
}
/** 重定向了输出(`>&2`、`> file`)的语句不算打印到输出。 */
function printsToOutput(statement: Node): boolean {
const name = commandName(commandOf(statement))
if (!name || !PRINTERS.has(name))
return false
return redirectsOf(statement).every(redirect => redirect.kind === 'read' || redirect.stream === 'error')
}
/** 函数自己的各句(进 if、循环、分情况,不进命令替换、管道、嵌套的函数)里打印到输出的那几句。 */
function printedStatements(body: Node): Node[] {
const out: Node[] = []
const visit = (node: Node): void => {
for (const child of named(node)) {
if (child.type === 'command' || child.type === 'redirected_statement') {
if (printsToOutput(child))
out.push(child)
}
else if (child.type !== 'function_definition' && child.type !== 'pipeline' && child.type !== 'command_substitution') {
visit(child)
}
}
}
visit(body)
return out
}
/** 同一文件里有没有 `$(name …)`:函数的输出被拿去用了。 */
function captured(root: Node, name: string): boolean {
return descendants(root, node => node.type === 'command_substitution'
&& named(node).some(inner => commandName(inner) === name || commandName(listOperands(inner)[0]) === name)).length > 0
}
function functionBlock(node: Node): CodeBlock {
const name = node.childForFieldName('name')
const body = node.childForFieldName('body')
const items = body?.type === 'compound_statement' ? bodyItems(children(body)) : []
const open = body?.child(0)
const shape = compoundShape(node, items[0]?.startIndex ?? open?.endIndex ?? node.endIndex, items, nested)
const facts: FunctionFacts = {
...(name && { name }),
decorators: [],
parameters: [],
positional: body ? positionalParameters(body) : [],
prints: body ? printedStatements(body) : [],
captured: !!name && captured(node.tree.rootNode, name.text),
returns: body ? descendants(body, child => commandName(child) === 'return') : [],
}
return { kind: 'function', ...shape, facts }
}
// ─── 解释器 ──────────────────────────────────────────────────────────────────
/** `#!/usr/bin/env bash`、`#!/bin/sh -e`。 */
function interpreterFacts(shebang: Node): InterpreterFacts {
const command = shebang.text.slice(2).trim()
const [program = '', ...rest] = command.split(/\s+/)
if (path.posix.basename(program) === 'env') {
const named = rest.find(word => !word.startsWith('-'))
return { program: named ? path.posix.basename(named) : 'env', lookup: 'env', command }
}
return { program: path.posix.basename(program), lookup: 'path', command }
}
function programBlocks(root: Node): CodeBlock[] {
const items = named(root)
const [first] = items
if (first?.type === 'comment' && first.startPosition.row === 0 && first.text.startsWith('#!')) {
const interpreter: CodeBlock = { kind: 'interpreter', ...leafShape(first), facts: interpreterFacts(first) }
return [interpreter, ...statements(items.slice(1), 0, true)]
}
return statements(items, -1, true)
}
// ─── 命令 ────────────────────────────────────────────────────────────────────
/** 一个 `>`、`>>`、`>&2`、`2>&1`、`&>`、`<` 重定向;heredoc 这类不算。 */
function redirectOf(node: Node): Redirect | undefined {
if (node.type !== 'file_redirect')
return undefined
const descriptor = named(node).find(child => child.type === 'file_descriptor')?.text
const operator = children(node).find(child => !child.isNamed)?.type
const target = node.childForFieldName('destination')
if (!operator || !target)
return undefined
const stream = descriptor === '2' ? 'error' : 'output'
switch (operator) {
case '>&':
if (target.text === '1' || target.text === '2')
return { kind: 'merge', stream, into: target.text === '2' ? 'error' : 'output' }
return { kind: 'write', stream, target }
case '>':
case '>|':
return { kind: 'write', stream, target }
case '>>':
return { kind: 'append', stream, target }
case '&>':
return { kind: 'write', stream: 'both', target }
case '&>>':
return { kind: 'append', stream: 'both', target }
case '<':
return { kind: 'read', source: target }
default:
return undefined
}
}
function redirectsOf(statement: Node): Redirect[] {
if (statement.type !== 'redirected_statement')
return []
return statement.childrenForFieldName('redirect').flatMap((redirect) => {
const found = redirect ? redirectOf(redirect) : undefined
return found ? [found] : []
})
}
/** 一个词写死的文字:去掉引号、反斜杠转义;里面有展开的没有。 */
function literalOf(word: Node): string | undefined {
switch (word.type) {
case 'word':
case 'number':
case 'extglob_pattern':
return word.text.replace(/\\(.)/g, '$1')
case 'raw_string':
return word.text.slice(1, -1)
case 'command_name': {
const [inner] = named(word)
return inner ? literalOf(inner) : undefined
}
case 'string': {
const parts = named(word)
return parts.every(part => part.type === 'string_content') ? parts.map(part => part.text.replace(/\\([$`"\\])/g, '$1')).join('') : undefined
}
case 'concatenation': {
const parts = named(word).map(literalOf)
return parts.every(part => part !== undefined) ? parts.join('') : undefined
}
default:
return undefined
}
}
function commandWord(node: Node): CommandWord {
const literal = literalOf(node)
return literal === undefined ? { node } : { node, literal }
}
/** 一条命令(带不带重定向):命令名、后面的各个词(连写死的文字)、重定向。 */
function bashCommand(node: Node): CommandFacts | undefined {
const command = commandOf(node)
const name = command?.childForFieldName('name')
if (!command || !name)
return undefined
const args = command.childrenForFieldName('argument').filter(arg => arg !== null)
return { name: commandWord(name), arguments: args.map(commandWord), redirects: redirectsOf(node) }
}
// ─── 名字 ────────────────────────────────────────────────────────────────────
function rangeOf(node: Node): SourceRange {
return { startIndex: node.startIndex, endIndex: node.endIndex }
}
/**
* 同一文件里认得出的名字:函数(定义处是函数名,用到的是同名的命令),变量(定义处是赋值、local / export 声明、for 的变量、
* read 读进的名字,用到的是 `$x`、`${x…}` 和这些定义处)。第几个参数、`$?` 这类不算。
*/
function bashSymbols(root: Node): LocalSymbol[] {
const symbols = new Map<string, LocalSymbol>()
const symbol = (key: string): LocalSymbol => {
let found = symbols.get(key)
if (!found) {
found = { definitions: [], occurrences: [] }
symbols.set(key, found)
}
return found
}
const define = (key: string, node: Node): void => {
const entry = symbol(key)
entry.definitions.push(rangeOf(node))
entry.occurrences.push(rangeOf(node))
}
const functions = new Set(descendants(root, node => node.type === 'function_definition').map(fn => fn.childForFieldName('name')?.text))
const visit = (node: Node): void => {
const parent = node.parent
if (node.type === 'function_definition') {
const name = node.childForFieldName('name')
if (name)
define(`function:${name.text}`, name)
}
else if (node.type === 'word' && parent?.type === 'command_name' && functions.has(node.text)) {
symbol(`function:${node.text}`).occurrences.push(rangeOf(node))
}
else if (node.type === 'variable_name' && !POSITIONAL.test(node.text)) {
const defining = parent?.type === 'variable_assignment' || parent?.type === 'declaration_command' || parent?.type === 'for_statement'
if (defining)
define(`variable:${node.text}`, node)
else if (parent?.type === 'simple_expansion' || parent?.type === 'expansion')
symbol(`variable:${node.text}`).occurrences.push(rangeOf(node))
}
else if (node.type === 'command' && commandName(node) === 'read') {
for (const arg of node.childrenForFieldName('argument')) {
if (arg?.type === 'word' && /^[a-z_]\w*$/i.test(arg.text))
define(`variable:${arg.text}`, arg)
}
}
for (const child of children(node))
visit(child)
}
visit(root)
return [...symbols.values()].filter(entry => entry.definitions.length > 0)
}
// ─── 词性 ────────────────────────────────────────────────────────────────────
function insideString(leaf: Node): boolean {
for (let node = leaf.parent; node; node = node.parent) {
if (EXPANSIONS.has(node.type))
return false
if (node.type === 'string' || node.type === 'heredoc_body')
return true
}
return false
}
function bashTokenRole(leaf: Node): TokenRole {
const parent = leaf.parent
switch (leaf.type) {
case 'comment':
return 'comment'
case 'string_content':
case 'raw_string':
case 'ansi_c_string':
case 'heredoc_content':
case 'heredoc_body':
case 'extglob_pattern':
case 'regex':
return 'string'
case 'number':
return 'number'
case 'variable_name':
return CONSTANT_NAME.test(leaf.text) ? 'constant' : 'variable'
case 'special_variable_name':
return 'variable'
case 'test_operator':
return 'operator'
case 'file_descriptor':
return 'number'
case 'word':
if (parent?.type === 'command_name' || parent?.type === 'function_definition')
return 'function'
if (insideString(leaf) || parent?.type === 'case_item')
return 'string'
return /^\d+(?:\.\d+)*$/.test(leaf.text) ? 'number' : 'plain'
case '"':
return 'string'
}
if (FLOW_KEYWORDS.has(leaf.type))
return 'keyword'
return DECLARATION_KEYWORDS.has(leaf.type) ? 'storage' : 'plain'
}
/** bash / sh / zsh 脚本(VS Code 的 languageId 是 shellscript)的语言插件。@param wasmDir 语法包 .wasm 所在目录 */
export function createBashAdapter(wasmDir: string): LanguageAdapter {
return {
id: 'bash',
languageIds: ['shellscript'],
grammarPath: () => path.join(wasmDir, 'tree-sitter-bash.wasm'),
toBlocks: root => programBlocks(root),
tokenRole: bashTokenRole,
command: bashCommand,
symbols: root => bashSymbols(root),
displayNames: bashNameReader,
}
}
@@ -0,0 +1,31 @@
import type { DisplayNameFacts, DisplayNameReader, Node } from '../contract'
import type { NameCandidate } from './displayNames'
import { COMMENT_MARK, displayNameFacts } from './displayNames'
import { children } from './shared'
/**
* shell 脚本里的中文名:变量赋值(含 local / export / readonly 声明的)行尾的 `# @Comment 名字`。
* shell 没有注解、装饰器,也没有写参数说明的习惯,只认行尾记号。
*/
function bashDisplayNames(root: Node, source: string): DisplayNameFacts {
const candidates: NameCandidate[] = []
const comments: Node[] = []
const visit = (node: Node): void => {
if (node.type === 'comment')
comments.push(node)
else if (node.type === 'variable_assignment' && node.childForFieldName('name'))
candidates.push({ name: node.childForFieldName('name') as Node, trailing: true })
else if (node.type === 'variable_name' && node.parent?.type === 'declaration_command')
candidates.push({ name: node, trailing: true })
for (const child of children(node))
visit(child)
}
visit(root)
return displayNameFacts(candidates, comments, source)
}
export const bashNameReader: DisplayNameReader = {
files: { shellscript: '**/*.{sh,bash,zsh}' },
mentions: source => source.includes(COMMENT_MARK),
find: (root, source) => bashDisplayNames(root, source),
}