1 <?php
2 /**
3 * Contains Ini class for Alphred
4 *
5 * PHP version 5
6 *
7 * @package Alphred
8 * @copyright Shawn Patrick Rice 2014
9 * @license http://opensource.org/licenses/MIT MIT
10 * @version 1.0.0
11 * @author Shawn Patrick Rice <rice@shawnrice.org>
12 * @link http://www.github.com/shawnrice/alphred
13 * @link http://shawnrice.github.io/alphred
14 * @since File available since Release 1.0.0
15 *
16 */
17
18 namespace Alphred;
19
20
21 /**
22 * Extends INI parsing and writing for PHP
23 *
24 * This class allows to read and write `ini` files. It translates `ini` files into
25 * associative PHP arrays and translates PHP arrays into `ini` files. It supports
26 * sectioning as well as a kind of subsectioning.
27 *
28 * Colons (`:`) are considered separators for sub-sections and are represented
29 * as multi-dimensional arrays. For instance, the following array:
30 * ````php
31 * $array = [
32 * 'Alphred' => [
33 * 'log_level' => 'DEBUG',
34 * 'log_size' => 10000,
35 * 'plugins' => [ 'get_password' => 'my_new_function' ]
36 * ]];
37 * ````
38 * will be represented as
39 * ````ini
40 * [Alphred]
41 * log_level = DEBUG
42 * log_size = 10000
43 *
44 * [Alphred:plugins]
45 * get_password = my_new_function
46 * ````
47 *
48 * If you are concerned, then make sure that `\r\n` is removed from the array values
49 * before they move into the INI file, as they may break them.
50 *
51 * All of these are static functions. So, to use:
52 * ````php
53 * $ini_file = Alphred\Ini::read_ini( '/path/to/workflow.ini' );
54 * ````
55 * That's it.
56 *
57 * To write an `ini` file, just do:
58 * ````php
59 * Alphred\Ini::write_ini( $config_array, '/path/to/workflow.ini' );
60 * ````
61 *
62 * @since 1.0.0
63 *
64 */
65 class Ini {
66
67 /**
68 * Parses an INI
69 *
70 * This is a slightly better INI parser in that will read a section title of
71 * 'title:subtitle' 'subtitle' as a subsection of the section 'title'.
72 *
73 * @since 1.0.0
74 *
75 * @param string $file path to the ini file to read
76 * @param boolean $exception whether or not to throw an exception on file not found
77 * @return array|boolean an array that represents the ini file
78 */
79 public function read_ini( $file, $exception = true ) {
80 if ( ! file_exists( $file ) ) {
81 if ( $exception ) {
82 throw new FileDoesNotExist( "File `{$file}` not found." );
83 } else {
84 return false;
85 }
86 }
87
88 // Parse the INI files
89 $ini = parse_ini_file( $file, true );
90 $array = [];
91 foreach( $ini as $key => $value ) :
92 if ( is_array( $value ) ) {
93 $array = array_merge_recursive( $array, self::parse_section( $key, $value ) );
94 } else {
95 $array[ $key ] = $value;
96 // array_unshift( $array, [ $key => $value ] );
97 }
98 endforeach;
99
100 return $array;
101
102 }
103
104 /**
105 * Writes an INI file from an array
106 *
107 * @since 1.0.0
108 * @todo Do filesystem checks
109 *
110 * @param array $array the array to be translated into an ini file
111 * @param string $file the full path to the ini file, should have '.ini'
112 */
113 public function write_ini( $array, $file ) {
114 // Collapse the arrays into writeable sections
115 $sections = self::collapse_sections( $array );
116 // Separate out the things that need to be in the global space from the things
117 // that need to be in sectioned spaces
118 $sections = self::separate_non_sections( $sections );
119 $global = $sections[0];
120 $sections = $sections[1];
121
122 // sort the sections
123 ksort( $sections );
124
125 $base = basename( $file );
126
127 // Write a header
128 $contents = ";;;;;\r\n";
129 $contents .= "; `{$base}` generated by Alphred v" . ALPHRED_VERSION . "\r\n";
130 $contents .= "; at " . date( 'Y-M-d H:i:s', time() ) . "\r\n";
131 $contents .= ";;;;;\r\n\r\n";
132
133 // Write things in the global space first
134 foreach( $global as $value ) :
135 // There should really be only one item in each array, but this is easy
136 foreach ( $value as $k => $v ) :
137 $contents .= "{$k} = {$v}\r\n";
138 endforeach;
139 endforeach;
140
141 // Now write out the sections
142 foreach ( $sections as $title => $section ) :
143
144 // Print the section
145 if ( is_array( $section ) ) {
146 if ( ! is_integer( $title ) ) {
147 $contents .= "\n[$title]\n";
148 }
149 $contents .= self::print_section( $section );
150 } else {
151 // Okay, the names are a bit weird here. This is
152 // actually key => value rather than title => section
153 // This is actually a deprecated part now, and we should
154 // never quite get here.
155 $contents .= "{$title} = {$section}\r\n";
156 }
157
158 endforeach;
159
160 file_put_contents( $file, $contents );
161 }
162
163 /**
164 * Separates out bits from the global space and from sections
165 *
166 * @param array $array array of values to write to an ini file
167 * @return array a sorted array
168 */
169 private function separate_non_sections( $array ) {
170 // Bad name for the method
171
172 // The global space
173 $global = [];
174 // Sectioned space
175 $sections = [];
176 foreach ( $array as $key => $value ) :
177 if ( is_array( $value ) ) {
178 // If it is an array, then we assume that it's a
179 // section, so put it in the sections array
180 $sections[ $key ] = $value;
181 } else {
182 // If it's not an array, then we assume that it needs
183 // to go in the global space, so put it in the global
184 // array
185 $global[] = [ $key => $value ];
186 }
187 endforeach;
188 // Return the sorted array
189 return [ $global, $sections ];
190
191 }
192
193 /**
194 * Prints the section of an INI file
195 *
196 * @since 1.0.0
197 *
198 * @param array $section an array
199 * @return string the array as an ini section
200 */
201 private function print_section( $section ) {
202 $contents = '';
203 foreach( $section as $key => $value ) :
204 if ( is_array( $value ) ) {
205 foreach( $value as $v ) :
206 $contents .= "{$key}[] = {$v}\r\n";
207 endforeach;
208 } else {
209 $contents .= "{$key} = {$value}\r\n";
210 }
211 endforeach;
212 return $contents;
213 }
214
215 /**
216 * Collapses arrays into something that can be written in the ini
217 *
218 * @since 1.0.0
219 *
220 * @param array $array the array to be collapsed
221 * @return array the collapsed array
222 */
223 private function collapse_sections( $array ) {
224 return self::step_back( self::flatten_array( $array ) );
225 }
226
227 /**
228 * Flattens an associate array
229 *
230 * @since 1.0.0
231 * @todo Better tests for numeric keys
232 *
233 * @param array $array an array to be flattened
234 * @param string $prefix a prefix for a key
235 * @return array the array, but flattened
236 */
237 private function flatten_array( $array, $prefix = '' ) {
238 if ( ! is_array( $array ) ) {
239 return $array;
240 }
241 if ( ! self::is_assoc( $array ) ) {
242 return $array;
243 }
244
245 $result = [];
246
247 foreach ( $array as $key => $value ) :
248
249 $new_key = $prefix . ( empty( $prefix ) ? '' : ':') . $key;
250
251 if ( is_integer( $key ) ) {
252 // Don't compound numeric keys; the assumption is that a numeric key will contain only
253 // one array. @todo test this further
254 foreach ( $value as $k => $v ) :
255 $result[ $k ] = $v;
256 endforeach;
257 } else if ( is_array( $value ) && self::is_assoc( $value ) ) {
258 $result = array_merge( $result, self::flatten_array( $value, $new_key ) );
259 } else {
260 $result[ $new_key ] = $value;
261 }
262 endforeach;
263
264 return $result;
265 }
266
267 /**
268 * Slightly unflattens an array
269 *
270 * So, flatten_array goes one step too far with the flattening, but I
271 * don't know how many levels down I need to flatten (2, 97?), so we just flatten
272 * all the way and then step back one level, which is what this function does.
273 *
274 * @since 1.0.0
275 *
276 * @param array $array a flattened array
277 * @return array a slightly less flat array
278 */
279 private function step_back( $array ) {
280 $new = [];
281 foreach( $array as $key => $value ) :
282 if ( substr_count( $key, ':' ) >= 1 ) {
283 $pos = strrpos( $key, ':' );
284 $section = substr( $key, 0, $pos );
285 $new_key = substr( $key, $pos + 1 );
286 $new[ $section ][ $new_key ] = $value;
287 } else {
288 $new[ $key ] = $value;
289 }
290 endforeach;
291 return $new;
292 }
293
294
295 /**
296 * Parses an ini section into its subsections
297 *
298 * @since 1.0.0
299 *
300 * @param string $name a string that should be turned into an array
301 * @param mixed $values the values for an array
302 * @return array the newly-dimensional array with $values
303 */
304 private function parse_section( $name, $values ) {
305 if ( false !== strpos( $name, ':' ) ) {
306 $pieces = explode( ':', $name );
307 $pieces = array_filter( $pieces, 'trim' );
308 } else {
309 return [ $name => $values ];
310 }
311 return self::nest_array( $pieces, $values );
312 }
313
314 /**
315 * Recursively nests an array
316 *
317 * @since 1.0.0
318 *
319 * @param array $array the pieces to nest
320 * @param mixed $values the values for the bottom level of the newly dimensional array
321 * @return array a slightly more dimensional array than we received
322 */
323 private function nest_array( $array, $values ) {
324 if ( empty( $array ) ) {
325 return $values;
326 }
327 return [ array_shift( $array ) => self::nest_array( $array, $values ) ];
328 }
329
330 /**
331 * Checks if an array is associative
332 *
333 * Shamelessly stolen from http://stackoverflow.com/a/14669600/1399574
334 *
335 * @since 1.0.0
336 *
337 * @param array $array an array
338 * @return boolean whether it is associative
339 */
340 private function is_assoc( $array ) {
341 // Keys of the array
342 $keys = array_keys( $array );
343
344 // If the array keys of the keys match the keys, then the array must
345 // not be associative (e.g. the keys array looked like {0:0, 1:1...}).
346 return array_keys( $keys ) !== $keys;
347 }
348 }
349