Skip to content

Commit f0416f8

Browse files
committed
fix: handle leading dynamic source paths when concatenating
This fixes `${var}/path` and `"${var}"/path` as well as countless variations of these patterns Note: this turned out to be rather complex task, so it's broken out into a seperate function, which allows for early returns
1 parent bf2d846 commit f0416f8

File tree

1 file changed

+91
-19
lines changed

1 file changed

+91
-19
lines changed

server/src/util/sourcing.ts

Lines changed: 91 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { untildify } from './fs'
99
import * as TreeSitterUtil from './tree-sitter'
1010

1111
const SOURCING_COMMANDS = ['source', '.']
12+
const VARIABLE_NODE_TYPES = ['expansion', 'simple_expansion']
1213

1314
export type SourceCommand = {
1415
range: LSP.Range
@@ -102,34 +103,36 @@ function getSourcedPathInfoFromNode({
102103
}
103104
}
104105

105-
if (argumentNode.type === 'word') {
106+
const strValue = resolveStaticString(argumentNode)
107+
if (strValue !== null) {
106108
return {
107-
sourcedPath: argumentNode.text,
109+
sourcedPath: strValue,
108110
}
109111
}
110112

111-
if (argumentNode.type === 'string' || argumentNode.type === 'raw_string') {
112-
const stringContents = argumentNode.text.slice(1, -1)
113-
if (argumentNode.namedChildren.length === 0) {
114-
return {
115-
sourcedPath: stringContents,
116-
}
117-
} else if (argumentNode.namedChildren.length === 1) {
118-
const [variableNode] = argumentNode.namedChildren
119-
if (
120-
variableNode.type == 'simple_expansion' ||
121-
variableNode.type == 'expansion'
122-
) {
123-
const variableText = `${variableNode.text}`
124-
if (stringContents.startsWith(variableText + '/')) {
125-
return {
126-
sourcedPath: '.' + stringContents.slice(variableText.length),
127-
}
113+
// Strip one leading dynamic section.
114+
if (argumentNode.type === 'string' && argumentNode.namedChildren.length === 1) {
115+
const [variableNode] = argumentNode.namedChildren
116+
if (VARIABLE_NODE_TYPES.includes(variableNode.type)) {
117+
const stringContents = argumentNode.text.slice(1, -1)
118+
if (stringContents.startsWith(`${variableNode.text}/`)) {
119+
return {
120+
sourcedPath: `.${stringContents.slice(variableNode.text.length)}`,
128121
}
129122
}
130123
}
131124
}
132125

126+
if (argumentNode.type === 'concatenation') {
127+
// Strip one leading dynamic section from a concatenation node.
128+
const sourcedPath = resolveSourceFromConcatenation(argumentNode)
129+
if (sourcedPath) {
130+
return {
131+
sourcedPath,
132+
}
133+
}
134+
}
135+
133136
// TODO: we could try to parse any ShellCheck "source "directive
134137
// # shellcheck source=src/examples/config.sh
135138
return {
@@ -181,3 +184,72 @@ function resolveSourcedUri({
181184

182185
return null
183186
}
187+
188+
/*
189+
* Resolves the source path from a concatenation node, stripping a leading dynamic directory segment.
190+
* Returns null if the source path can't be statically determined after stripping a segment.
191+
* Note: If a non-concatenation node is passed, null will be returned. This is likely a programmer error.
192+
*/
193+
function resolveSourceFromConcatenation(node: Parser.SyntaxNode): string | null {
194+
if (node.type !== 'concatenation') return null
195+
const stringValue = resolveStaticString(node)
196+
if (stringValue !== null) return stringValue // This string is fully static.
197+
198+
const values: string[] = []
199+
// Since the string must begin with the variable, the variable must be in the first child.
200+
const [firstNode, ...rest] = node.namedChildren
201+
// The first child is static, this means one of the other children is not!
202+
if (resolveStaticString(firstNode) !== null) return null
203+
204+
// if the string is unquoted, the first child is the variable, so there's no more text in it.
205+
if (!VARIABLE_NODE_TYPES.includes(firstNode.type)) {
206+
if (firstNode.namedChildCount > 1) return null // Only one variable is allowed.
207+
// Since the string must begin with the variable, the variable must be first child.
208+
const variableNode = firstNode.namedChildren[0] // Get the variable (quoted case)
209+
// This is command substitution!
210+
if (!VARIABLE_NODE_TYPES.includes(variableNode.type)) return null
211+
const stringContents = firstNode.text.slice(1, -1)
212+
// The string doesn't start with the variable!
213+
if (!stringContents.startsWith(variableNode.text)) return null
214+
// Get the remaining static portion the string
215+
values.push(stringContents.slice(variableNode.text.length))
216+
}
217+
218+
for (const child of rest) {
219+
const value = resolveStaticString(child)
220+
// The other values weren't statically determinable!
221+
if (value === null) return null
222+
values.push(value)
223+
}
224+
225+
// Join all our found static values together.
226+
const staticResult = values.join('')
227+
// The path starts with slash, so trim the leading variable and replace with a dot
228+
if (staticResult.startsWith('/')) return `.${staticResult}`
229+
// The path doesn't start with a slash, so it's invalid
230+
// PERF: can we fail earlier than this?
231+
return null
232+
}
233+
234+
/**
235+
* Resolves the full string value of a node
236+
* Returns null if the value can't be statically determined (ie, it contains a variable or command substition).
237+
* Supports: word, string, raw_string, and concatenation
238+
*/
239+
function resolveStaticString(node: Parser.SyntaxNode): string | null {
240+
if (node.type === 'concatenation') {
241+
const values = []
242+
for (const child of node.namedChildren) {
243+
const value = resolveStaticString(child)
244+
if (value === null) return null
245+
values.push(value)
246+
}
247+
return values.join('')
248+
}
249+
if (node.type === 'word') return node.text
250+
if (node.type === 'string' || node.type === 'raw_string') {
251+
if (node.namedChildCount === 0) return node.text.slice(1, -1)
252+
return null
253+
}
254+
return null
255+
}

0 commit comments

Comments
 (0)