1<?php
2
3namespace Shellbox\ShellParser;
4
5class SyntaxInfo {
6	/** @var Node */
7	private $root;
8
9	/** @var string[]|null */
10	private $featureList;
11
12	/** @var string[]|null */
13	private $literalArgv;
14
15	public const LIST = 'list';
16	public const BACKGROUND = 'background';
17	public const PIPELINE = 'pipeline';
18	public const COMPOUND = 'compound';
19	public const REDIRECT = 'redirect';
20	public const COMMAND_EXPANSION = 'command_expansion';
21	public const PARAMETER = 'parameter';
22	public const EXOTIC_EXPANSION = 'exotic_expansion';
23	public const ASSIGNMENT = 'assignment';
24
25	/**
26	 * @internal Use SyntaxTree::getInfo()
27	 *
28	 * @param Node $root
29	 */
30	public function __construct( $root ) {
31		$this->root = $root;
32	}
33
34	/**
35	 * @var array Node types used to identify features. Note that features do
36	 *   not need to be mutually exclusive.
37	 */
38	private static $nodeTypesByFeature = [
39		'list' => [ 'list', 'and_if', 'or_if' ],
40		'background' => [ 'background' ],
41		'pipeline' => [ 'pipeline' ],
42		'compound' => [
43			'subshell',
44			'for',
45			'case',
46			'if',
47			'while',
48			'until',
49			'function_definition',
50			'brace_group'
51		],
52		'redirect' => [ 'io_redirect' ],
53		'command_expansion' => [ 'backquote', 'command_expansion' ],
54		'parameter' => [ 'special_parameter', 'positional_parameter', 'named_parameter' ],
55		'exotic_expansion' => [
56			'use_default',
57			'use_default_unset',
58			'assign_default',
59			'assign_default_unset',
60			'indicate_error',
61			'indicate_error_unset',
62			'use_alternative',
63			'use_alternative_unset',
64			'remove_smallest_suffix',
65			'remove_largest_suffix',
66			'remove_smallest_prefix',
67			'remove_largest_prefix',
68			'string_length',
69			'arithmetic_expansion',
70			'braced_parameter_expansion'
71		],
72		'assignment' => [ 'assignment' ],
73	];
74
75	/**
76	 * @var array Features by node type, compiled with compileFeaturesByNodeType().
77	 */
78	private static $featuresByNodeType = [
79		'and_if' => [ 'list' ],
80		'arithmetic_expansion' => [ 'exotic_expansion' ],
81		'assign_default' => [ 'exotic_expansion' ],
82		'assign_default_unset' => [ 'exotic_expansion' ],
83		'assignment' => [ 'assignment' ],
84		'background' => [ 'background' ],
85		'backquote' => [ 'command_expansion' ],
86		'brace_group' => [ 'compound' ],
87		'braced_parameter_expansion' => [ 'exotic_expansion' ],
88		'case' => [ 'compound' ],
89		'command_expansion' => [ 'command_expansion' ],
90		'for' => [ 'compound' ],
91		'function_definition' => [ 'compound' ],
92		'if' => [ 'compound' ],
93		'indicate_error' => [ 'exotic_expansion' ],
94		'indicate_error_unset' => [ 'exotic_expansion' ],
95		'io_redirect' => [ 'redirect' ],
96		'list' => [ 'list' ],
97		'named_parameter' => [ 'parameter' ],
98		'or_if' => [ 'list' ],
99		'pipeline' => [ 'pipeline' ],
100		'positional_parameter' => [ 'parameter' ],
101		'remove_largest_prefix' => [ 'exotic_expansion' ],
102		'remove_largest_suffix' => [ 'exotic_expansion' ],
103		'remove_smallest_prefix' => [ 'exotic_expansion' ],
104		'remove_smallest_suffix' => [ 'exotic_expansion' ],
105		'special_parameter' => [ 'parameter' ],
106		'string_length' => [ 'exotic_expansion' ],
107		'subshell' => [ 'compound' ],
108		'until' => [ 'compound' ],
109		'use_alternative' => [ 'exotic_expansion' ],
110		'use_alternative_unset' => [ 'exotic_expansion' ],
111		'use_default' => [ 'exotic_expansion' ],
112		'use_default_unset' => [ 'exotic_expansion' ],
113		'while' => [ 'compound' ],
114	];
115
116	/**
117	 * A function for use from a PHP CLI which inverts the $nodeTypesByFeature
118	 * array to produce $featuresByNodeType.
119	 *
120	 * @return string
121	 */
122	public static function compileFeaturesByNodeType() {
123		$featuresByNodeType = [];
124		foreach ( self::$nodeTypesByFeature as $feature => $types ) {
125			foreach ( $types as $type ) {
126				$featuresByNodeType[$type][] = $feature;
127			}
128		}
129		$s = '';
130		ksort( $featuresByNodeType );
131		foreach ( $featuresByNodeType as $type => $features ) {
132			$s .= "\t'$type' => [ '" . implode( "', '", $features ) . "' ],\n";
133		}
134		return "private static \$featuresByNodeType = [\n$s];\n";
135	}
136
137	/**
138	 * Get the features used in this shell program
139	 *
140	 * @return array
141	 */
142	public function getFeatureList() {
143		if ( $this->featureList === null ) {
144			$features = [];
145			$this->root->traverse(
146				function ( $node ) use ( &$features ) {
147					if ( $node instanceof Node ) {
148						$newFeatures = self::$featuresByNodeType[$node->type] ?? [];
149						foreach ( $newFeatures as $feature ) {
150							$features[$feature] = true;
151						}
152					}
153				}
154			);
155			$this->featureList = array_keys( $features );
156		}
157		return $this->featureList;
158	}
159
160	/**
161	 * If the program is a single command and all of its arguments can be
162	 * represented as string literals, return the unquoted literals. Otherwise,
163	 * return null.
164	 *
165	 * @return string[]|null
166	 */
167	public function getLiteralArgv() {
168		if ( $this->literalArgv !== null ) {
169			return $this->literalArgv;
170		}
171
172		$argv = [];
173		$node = $this->root;
174		if ( $this->getNodeType( $node ) !== 'program' || $this->getChildCount( $node ) !== 1 ) {
175			return null;
176		}
177		$node = $node->contents[0];
178		if ( $this->getNodeType( $node ) !== 'complete_command' || $this->getChildCount( $node ) !== 1 ) {
179			return null;
180		}
181		$node = $node->contents[0];
182		if ( $this->getNodeType( $node ) !== 'simple_command' ) {
183			return null;
184		}
185		foreach ( $this->getChildren( $node ) as $child ) {
186			$type = $this->getNodeType( $child );
187			if ( $type !== 'word' ) {
188				continue;
189			}
190			$unquotedWord = $this->unquoteWord( $child );
191			if ( $unquotedWord === null ) {
192				return null;
193			}
194			$argv[] = $unquotedWord;
195		}
196		$this->literalArgv = $argv;
197		return $argv;
198	}
199
200	/**
201	 * @param string|array|Node $node
202	 * @return string
203	 */
204	private function getNodeType( $node ) {
205		if ( $node instanceof Node ) {
206			return $node->type;
207		} elseif ( is_array( $node ) ) {
208			return 'array';
209		} else {
210			return 'string';
211		}
212	}
213
214	/**
215	 * @param string|array|Node $node
216	 * @return array
217	 */
218	private function getChildren( $node ) {
219		if ( $node instanceof Node ) {
220			return $node->contents;
221		} else {
222			return [];
223		}
224	}
225
226	/**
227	 * @param string|array|Node $node
228	 * @return int
229	 */
230	private function getChildCount( $node ) {
231		return count( $this->getChildren( $node ) );
232	}
233
234	/**
235	 * Remove quotes from a word node. If the word cannot be converted to a
236	 * literal string, return null.
237	 *
238	 * @param Node $word
239	 * @return string|null
240	 */
241	private function unquoteWord( Node $word ) {
242		$unquotedWord = '';
243		foreach ( $this->getChildren( $word ) as $part ) {
244			$type = $this->getNodeType( $part );
245			if ( $type === 'single_quote'
246				|| $type === 'unquoted_literal'
247				|| $type === 'bare_escape'
248			) {
249				if ( $this->getChildCount( $part ) !== 1 ) {
250					return null;
251				}
252				$literalPart = $this->getChildren( $part )[0];
253				if ( !is_string( $literalPart ) ) {
254					return null;
255				}
256				$unquotedWord .= $literalPart;
257			} elseif ( $type === 'double_quote' ) {
258				$literalPart = $this->unquoteDoubleQuote( $part );
259				if ( !is_string( $literalPart ) ) {
260					return null;
261				}
262				$unquotedWord .= $literalPart;
263			} else {
264				return null;
265			}
266		}
267		return $unquotedWord;
268	}
269
270	/**
271	 * Remove quotes from a double-quote node. If it contains expansions, null
272	 * will be returned.
273	 *
274	 * @param Node $dquote
275	 * @return string|null
276	 */
277	private function unquoteDoubleQuote( Node $dquote ) {
278		$unquoted = '';
279		foreach ( $this->getChildren( $dquote ) as $part ) {
280			$type = $this->getNodeType( $part );
281			if ( $type === 'string' ) {
282				$unquoted .= $part;
283			} elseif ( $type === 'dquoted_escape' ) {
284				if ( $this->getChildCount( $part ) !== 1 ) {
285					return null;
286				}
287				$literalPart = $this->getChildren( $part )[0];
288				if ( !is_string( $literalPart ) ) {
289					return null;
290				}
291				$unquoted .= $literalPart;
292			} else {
293				return null;
294			}
295		}
296		return $unquoted;
297	}
298}
299