@@ -9,6 +9,7 @@ import { untildify } from './fs'
99import * as TreeSitterUtil from './tree-sitter'
1010
1111const SOURCING_COMMANDS = [ 'source' , '.' ]
12+ const VARIABLE_NODE_TYPES = [ 'expansion' , 'simple_expansion' ]
1213
1314export 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