$path, 'readable' => false, 'writable' => false, 'eol' => "\n", 'blocks' => array(), 'defines' => array(), 'conflicts' => array(), 'disabled' => array(), 'kept' => array(), 'notes' => array(), 'anchor' => array( 'type' => 0, 'offset' => 0, 'line' => 0, ), 'require' => 0, 'parse_error' => '', ); } /** * Everything that can be told from the bytes themselves. * * Separate from read() so that write() can analyse the very bytes it is * about to edit. Reading the file twice - once to find the offsets, once to * change them - would leave a window in which the file changes between the * two, and every offset would then point somewhere slightly wrong. In a * file where a misplaced character is a white screen, that window is not * acceptable. * * @param string $src Config file contents. * @param array $state Result of blank(), with the path already filled in. * @return array Structure described by read(). */ private static function analyse( $src, array $state ) { $state['readable'] = true; $state['eol'] = self::eol( $src ); try { $scan = self::scan( $src ); } catch ( ParseError $e ) { /* * Nearly unreachable: the config is being executed right now, so it * parses. It still has to be caught - the file may have changed * since this request started, and the writing half reuses this same * call to check what it has just built. */ $state['parse_error'] = $e->getMessage(); return $state; } $state = array_merge( $state, $scan ); /* * Above or below the insertion point are two different problems: above, * the foreign define() wins and ours is ignored silently; below, ours * wins and the foreign line starts logging "Constant already defined" * into our own log. Both get commented out, but the page says different * things about them. */ foreach ( $state['defines'] as $key => $define ) { $state['defines'][ $key ]['position'] = ( $state['anchor']['offset'] > 0 && $define['offset_start'] < $state['anchor']['offset'] ) ? 'before' : 'after'; /* * Whether the statement has its lines to itself is worked out here, * where the bytes are, and not inside the write: the page has to be * able to say beforehand that this constant cannot be taken over. * A refusal that only appears after the button is pressed reads as * a malfunction. */ $span = null === $define['offset_end'] ? null : self::line_span( $src, $define['offset_start'], $define['offset_end'] ); $state['defines'][ $key ]['span'] = $span; $state['defines'][ $key ]['alone'] = null !== $span; } /* * The text of each block, cut with the very offsets the removal will * act on. The page prints it before the button is pressed, and cutting * it here rather than re-reading the file by line numbers is what keeps * the preview and the edit from ever disagreeing: one source of bytes, * not two. */ foreach ( $state['blocks'] as $key => $block ) { $state['blocks'][ $key ]['text'] = self::span_text( $src, $block ); } /* * And what each commented-out line will look like once it is given * back. Produced by uncomment(), the same call the removal makes, for * the same reason: a second way of stripping the prefix would be a * second answer to the same question. */ foreach ( $state['disabled'] as $key => $line ) { $state['disabled'][ $key ]['restored'] = self::uncomment( $line['text'] ); } return $state; } /** * The bytes between two offsets, or nothing when there are none. * * @param string $src Config source. * @param array $block Entry carrying offset_start and offset_end. * @return string */ private static function span_text( $src, array $block ) { $start = $block['offset_start']; $end = $block['offset_end']; if ( null === $start || null === $end || $end <= $start ) { return ''; } return (string) substr( $src, (int) $start, (int) $end - (int) $start ); } /** * Builds the block exactly as it goes into wp-config.php. * * One builder, two callers: the page shows this text, the writing half * inserts it. Two builders would drift apart, and the whole point of * showing it beforehand is that the person sees what will be inserted - * not something close to it. * * English and not translated. It lands in a PHP file read by whoever keeps * the server running, possibly years later and without this plugin * installed; besides, nothing in this class is translated at all - see the * note on the journal above. * * The block names two places: the loader, and the plugin's folder in * uploads, which the loader cannot work out for itself - WordPress has not * loaded yet when it runs (2026-10-11). Both are written with the block, * and changed afterwards only by replace_block(), on a button press, when * they no longer lead where they should (2026-10-15). * * @param string $config_path Absolute path to wp-config.php. * @param string $boot_file Absolute path to this plugin's boot.php. * @param string $base_dir Absolute path to the plugin's folder in uploads. * @param string $eol Line ending to build with. * @return string Block text, without a trailing line break. */ public static function block( $config_path, $boot_file, $base_dir, $eol = "\n" ) { $path = self::path_expression( $config_path, $boot_file ); $base = self::path_expression( $config_path, $base_dir ); $lines = array( '/* ' . self::MARKER_BEGIN . ' ' . self::BLOCK_VERSION, ' This plugin manages ' . implode( ', ', self::MANAGED ) . '.', ' Lines marked ' . self::MARKER_DISABLED . ' above were commented out by it.', ' To remove by hand: delete this block, then drop those prefixes. */', 'if ( file_exists( ' . $path . ' ) ) {', "\tdefine( 'DLOGR_BASE_DIR', " . $base . ' );', "\trequire_once " . $path . ';', '}', '/* ' . self::MARKER_END . ' ' . self::BLOCK_VERSION . ' */', ); return implode( $eol, $lines ); } /** * A path expression that goes inside the block. * * Relative to the config file whenever it can be. Two reasons, and the * second is the ordinary one: wp-config.php may sit one directory above * WordPress - a layout the core supports deliberately - and an absolute * path stops being true the moment the site is moved between dev, staging * and production. * * When the target is not under the config's directory at all - plugins or * uploads on another Windows drive, or on a network share - an absolute * path is the only truthful answer. Being wrong either way is survivable: * the file_exists() guard in the block turns a missed loader path into * silently disabled logging rather than a white screen, and a missed * folder leaves the loader without data, which the Status tab reports. * * @param string $config_path Absolute path to wp-config.php. * @param string $target Absolute path to the file or folder to name. * @return string PHP expression, ready to be pasted into the block. */ private static function path_expression( $config_path, $target ) { $dir = self::slashes( dirname( $config_path ) ); $path = self::slashes( $target ); /* * Windows compares paths without regard to case, and these reach us * from different places - the config from ABSPATH, the loader from the * plugin file, the folder from wp_upload_dir() - so the same directory * can arrive spelled differently. Anywhere else case is part of the * name and has to match exactly, otherwise two genuinely different * directories would be taken for one. */ $inside = ( '\\' === DIRECTORY_SEPARATOR ) ? 0 === stripos( $path, $dir . '/' ) : 0 === strpos( $path, $dir . '/' ); if ( $inside ) { return "__DIR__ . '" . self::quote( substr( $path, strlen( $dir ) ) ) . "'"; } return "'" . self::quote( $path ) . "'"; } /** * Puts our block into the config, taking the constants over on the way. * * ⚠️ The caller is responsible for establishing that the direct filesystem * method is available. get_filesystem_method() lives in an admin include * that need not be loaded where this class runs, so that gate belongs to * whoever presses the button. * * Nothing is written until the finished text has been parsed and checked. * Every refusal leaves the config byte for byte as it was. * * @param string $config_path Absolute path to wp-config.php. * @param string $boot_file Absolute path to this plugin's boot.php. * @param string $base_dir Absolute path to the plugin's folder in uploads. * @return true|WP_Error */ public static function write( $config_path, $boot_file, $base_dir ) { if ( ! is_readable( $config_path ) ) { return new WP_Error( 'dlogr_config_unreadable', 'wp-config.php cannot be read, so it will not be changed.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- WP_Filesystem may ask for credentials, and these are the exact bytes that are about to be edited: see analyse(). $src = file_get_contents( $config_path ); if ( false === $src ) { return new WP_Error( 'dlogr_config_unreadable', 'wp-config.php cannot be read, so it will not be changed.' ); } $state = self::analyse( $src, self::blank( $config_path ) ); $refusal = self::refuse( $state, $src ); if ( is_wp_error( $refusal ) ) { return $refusal; } $block = self::block( $config_path, $boot_file, $base_dir, $state['eol'] ); $edits = self::edits( $state, $src, $block ); if ( is_wp_error( $edits ) ) { return $edits; } $new = self::apply( $src, $edits['list'] ); $checked = self::verify( $new, $edits['disabled'] ); if ( is_wp_error( $checked ) ) { return $checked; } return self::swap( $config_path, $new ); } /** * Writes our block again in its own place, with the paths as they are now. * * For a block that no longer loads the plugin: the site was moved and the * loader path in the block is dead, the uploads folder changed after the * block was written, or the block is of an earlier format (заказчик, * 2026-10-15). Only the text between our two markers changes. The lines * this plugin commented out stay commented out, so the owner's WP_DEBUG * never comes back to life halfway - which is what taking the block out * and putting it back would do, in two writes instead of one. * * ⚠️ Same as write(), the caller establishes that the direct method is * available. And same as write(), every refusal leaves the config byte for * byte as it was. * * @param string $config_path Absolute path to wp-config.php. * @param string $boot_file Absolute path to this plugin's boot.php. * @param string $base_dir Absolute path to the plugin's folder in uploads. * @return true|WP_Error */ public static function replace_block( $config_path, $boot_file, $base_dir ) { if ( ! is_readable( $config_path ) ) { return new WP_Error( 'dlogr_config_unreadable', 'wp-config.php cannot be read, so it will not be changed.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- The same bytes that are about to be edited; see analyse(). $src = file_get_contents( $config_path ); if ( false === $src ) { return new WP_Error( 'dlogr_config_unreadable', 'wp-config.php cannot be read, so it will not be changed.' ); } $state = self::analyse( $src, self::blank( $config_path ) ); $refusal = self::refuse( $state, $src ); if ( is_wp_error( $refusal ) ) { return $refusal; } /* * Exactly one, and with both ends known. With two there is no telling * which one the owner means to keep, and a closing marker without an * opening one has no start to replace from. */ if ( 1 !== count( $state['blocks'] ) || null === $state['blocks'][0]['offset_start'] ) { return new WP_Error( 'dlogr_config_block_count', 'wp-config.php does not hold exactly one complete block of this plugin, so there is no telling which one to update. It was left alone.' ); } $old = $state['blocks'][0]; $block = self::block( $config_path, $boot_file, $base_dir, $state['eol'] ); if ( $old['text'] === $block ) { return new WP_Error( 'dlogr_config_block_current', 'The block in wp-config.php already names the current paths, so there was nothing to update.' ); } $new = self::apply( $src, array( array( 'start' => $old['offset_start'], 'end' => $old['offset_end'], 'replacement' => $block, ), ) ); $checked = self::verify_replacement( $new, $state ); if ( is_wp_error( $checked ) ) { return $checked; } return self::swap( $config_path, $new ); } /** * Reads back a replaced block, before any of it reaches the disk. * * Not verify(): that one demands that no managed define() is live, which * is right for a first insertion and wrong here. A declaration somebody * added after the block went in is theirs; replacing our block neither * adds nor removes it, and refusing over it would leave the block broken * for a reason that has nothing to do with it. What must hold is that * nothing outside the block changed its meaning. * * @param string $content The finished text. * @param array $before Result of analyse() on the text before the edit. * @return true|WP_Error */ private static function verify_replacement( $content, array $before ) { try { $scan = self::scan( $content ); } catch ( ParseError $e ) { return new WP_Error( 'dlogr_config_would_break', sprintf( 'The edited wp-config.php would not parse (%s), so it was thrown away and the file left alone.', $e->getMessage() ) ); } if ( 1 !== count( $scan['blocks'] ) || ! $scan['blocks'][0]['closed'] ) { return new WP_Error( 'dlogr_config_block_count', 'The edited wp-config.php did not end up with exactly one complete block, so it was thrown away.' ); } if ( 0 === $scan['require'] ) { return new WP_Error( 'dlogr_config_no_require', 'The edited wp-config.php lost the line that loads wp-settings.php, so it was thrown away.' ); } if ( count( $scan['defines'] ) !== count( $before['defines'] ) ) { return new WP_Error( 'dlogr_config_define_lost', 'The number of live declarations of the managed constants changed with the edit, so the result was thrown away.' ); } if ( count( $scan['disabled'] ) !== count( $before['disabled'] ) ) { return new WP_Error( 'dlogr_config_marker_count', 'The number of lines marked as commented out by this plugin changed with the edit, so the result was thrown away.' ); } return true; } /** * Takes our block out again and gives the constants back. * * The mirror of write(), and deliberately not a restore from a backup: * other plugins, the host and the owner may all have edited this file since * we touched it, and putting an old copy back would wipe their work. * * "Clean" therefore means: no block of ours, and the lines we commented out * are live again. Everything else in the file is none of our business * (уточнено заказчиком 2026-09-05). * * ⚠️ Same as write(), the caller establishes that the direct method is * available. * * @param string $config_path Absolute path to wp-config.php. * @return true|WP_Error */ public static function remove( $config_path ) { if ( ! is_readable( $config_path ) ) { return new WP_Error( 'dlogr_config_unreadable', 'wp-config.php cannot be read, so it will not be changed.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- The same bytes that are about to be edited; see analyse(). $src = file_get_contents( $config_path ); if ( false === $src ) { return new WP_Error( 'dlogr_config_unreadable', 'wp-config.php cannot be read, so it will not be changed.' ); } $state = self::analyse( $src, self::blank( $config_path ) ); if ( '' !== $state['parse_error'] ) { return new WP_Error( 'dlogr_config_parse_error', 'wp-config.php did not parse, so what is in it is unknown and nothing will be changed.' ); } if ( empty( $state['blocks'] ) && empty( $state['disabled'] ) ) { return new WP_Error( 'dlogr_config_nothing_to_remove', 'There is nothing of this plugin in wp-config.php to remove.' ); } foreach ( $state['blocks'] as $block ) { if ( ! $block['closed'] ) { /* * ⚠️ Refused, even though this is exactly the mess somebody * would want cleaned up. Where an unclosed block ends is a * guess, and a guess here deletes somebody else's lines while * leaving a file that still parses - the one kind of damage no * check downstream can catch. */ return new WP_Error( 'dlogr_config_unclosed_block', sprintf( 'The block starting on line %d has no closing marker, so where it ends is unknown. It has to be removed by hand.', $block['line_start'] ) ); } } $edits = array(); $restored = 0; foreach ( $state['blocks'] as $block ) { $span = self::block_span( $src, $block ); $edits[] = array( 'start' => $span['start'], 'end' => $span['end'], 'replacement' => '', ); } $live = array(); foreach ( $state['defines'] as $define ) { $live[] = $define['name']; } foreach ( self::disabled_groups( $state ) as $group ) { /* * ⚠️ Giving this line back would declare the same constant twice. * PHP calls that a warning today and an error in PHP 9, so the line * stays commented - under a different marker, with the reason * written above it in plain words. Which of the two declarations is * right is the owner's call, not ours (решение заказчика * 2026-09-05). */ $conflict = '' !== $group['name'] && in_array( $group['name'], $live, true ); foreach ( $group['lines'] as $line ) { $edits[] = array( 'start' => $line['offset_start'], 'end' => $line['offset_end'], 'replacement' => $conflict ? self::keep_commented( $line['text'] ) : self::uncomment( $line['text'] ), ); } if ( $conflict ) { $edits[] = self::conflict_note( $src, $group, $state['eol'] ); continue; } $restored += count( $group['lines'] ); } $edits = array_merge( $edits, self::stale_notes( $state, $src ) ); $new = self::apply( $src, $edits ); $checked = self::verify_removal( $new, count( $state['defines'] ) ); if ( is_wp_error( $checked ) ) { return $checked; } return self::swap( $config_path, $new ); } /** * Commented out lines, grouped into the statements they were cut from. * * One define() can span four lines, and a // comment token is one line, so * the lines have to be put back together before anything can be said about * which constant they declare. * * ⚠️ Adjacency is not the test. Two single-line declarations sitting one * under the other are adjacent too, and grouping by adjacency merged them * into one - so a conflict over the first constant kept the second one * commented out as well. Lines are therefore accumulated until they parse * as a whole statement, which is the only definition of "one statement" * that cannot be argued with. Caught by a fixture, not by reasoning. * * Lines inside one of our blocks are skipped, and that is not hypothetical: * the block's own text explains what the marker means. Without the skip the * two edits would overlap, and overlapping splices corrupt a file instead * of failing loudly. * * @param array $state Result of analyse(). * @return array Groups with keys lines and name. */ private static function disabled_groups( array $state ) { $result = array(); $buffer = array(); $last = null; foreach ( $state['disabled'] as $line ) { if ( self::inside_block( $line, $state['blocks'] ) ) { continue; } // A gap in the line numbers ends whatever was being collected. if ( null !== $last && $line['line'] !== $last + 1 && ! empty( $buffer ) ) { $result[] = self::close_group( $buffer ); $buffer = array(); } $buffer[] = $line; $last = $line['line']; $name = self::group_constant( $buffer ); if ( '' !== $name ) { $result[] = array( 'lines' => $buffer, 'name' => $name, ); $buffer = array(); } } if ( ! empty( $buffer ) ) { $result[] = self::close_group( $buffer ); } return $result; } /** * Closes a group of lines that never parsed into a declaration of ours. * * Left nameless on purpose: without a constant there is nothing to conflict * with, so the lines are simply given back as they were. Somebody editing * a commented out line by hand ends up here, and their text is theirs. * * @param array $lines Lines collected so far. * @return array One group. */ private static function close_group( array $lines ) { return array( 'lines' => $lines, 'name' => '', ); } /** * Which of our constants a group of commented out lines declares. * * Answered by the tokenizer on the uncommented text rather than by a * regular expression: the point of the whole class is that a define() is * recognised the way PHP recognises it, and this decision - restore the * line or keep it commented - is no place to start guessing. * * @param array $lines Lines of one group. * @return string Constant name, empty when it is not one of ours. */ private static function group_constant( array $lines ) { $text = ''; foreach ( $lines as $line ) { $text .= self::uncomment( $line['text'] ); } try { $scan = self::scan( ' $start, 'end' => $start, 'replacement' => $note, ); } /** * Edits that delete explanations which no longer explain anything. * * An explanation is valid only while the line it stands over is still a * kept one. Somebody who does what it says - deletes the duplicate * declaration and drops the prefix - turns that line back into ordinary * code, and the paragraph above it becomes a statement about a state that * no longer exists, signed with this plugin's name (решение заказчика * 2026-09-05). * * ⚠️ Checked on both operations, not only on removal. It goes stale on an * **insertion**: the revived line is commented out again as DLOGR-DISABLED, * and the note above it starts describing the wrong marker - by our own * hand, without a single mistake by the person. * * @param array $state Result of analyse(). * @param string $src Config file contents. * @return array Edit list entries, possibly empty. */ private static function stale_notes( array $state, $src ) { $edits = array(); foreach ( $state['notes'] as $note ) { if ( self::note_is_live( $note, $state, $src ) ) { continue; } $edits[] = array( 'start' => self::back_over_blank( $src, $note['offset_start'] ), 'end' => self::forward_over_eol( $src, $note['offset_end'] ), 'replacement' => '', ); } return $edits; } /** * Whether a kept line still stands directly under this explanation. * * Directly: whitespace may sit between them, nothing else. Anything else * there means the pair has been broken up by hand, and the explanation is * then no longer attached to what it explains. * * @param array $note One entry of the notes list. * @param array $state Result of analyse(). * @param string $src Config file contents. * @return bool */ private static function note_is_live( array $note, array $state, $src ) { $after = $note['offset_end']; $gap = strspn( $src, " \t\r\n", $after ); foreach ( $state['kept'] as $kept ) { if ( $kept['offset_start'] === $after + $gap ) { return true; } } return false; } /** * Whether a commented line sits inside one of our blocks. * * @param array $line One entry of the disabled list. * @param array $blocks Blocks found in the file. * @return bool */ private static function inside_block( array $line, array $blocks ) { foreach ( $blocks as $block ) { if ( null === $block['offset_start'] || null === $block['offset_end'] ) { continue; } if ( $line['offset_start'] >= $block['offset_start'] && $line['offset_end'] <= $block['offset_end'] ) { return true; } } return false; } /** * Strips our prefix and gives the line back as code. * * Only the prefix. What follows is returned untouched, including the line * break a // comment token carries with it and anything the owner may have * edited inside the commented line since. * * @param string $text Comment token beginning with our marker. * @return string */ private static function uncomment( $text ) { return preg_replace( '#^(?:/\*+|//|\#)\s*' . preg_quote( self::MARKER_DISABLED, '#' ) . '[ \t]?#', '', $text, 1 ); } /** * Reads back the file with our part taken out. * * @param string $content The finished text. * @param int $live Managed constants that were already active before. * @return true|WP_Error */ private static function verify_removal( $content, $live ) { try { $scan = self::scan( $content ); } catch ( ParseError $e ) { return new WP_Error( 'dlogr_config_would_break', sprintf( 'wp-config.php without our block would not parse (%s), so it was thrown away and the file left alone.', $e->getMessage() ) ); } if ( ! empty( $scan['blocks'] ) ) { return new WP_Error( 'dlogr_config_block_left', 'A block of this plugin survived the edit, so the result was thrown away.' ); } if ( ! empty( $scan['disabled'] ) ) { return new WP_Error( 'dlogr_config_marker_left', 'A line commented out by this plugin was still marked afterwards, so the result was thrown away.' ); } if ( 0 === $scan['require'] ) { return new WP_Error( 'dlogr_config_no_require', 'The edited wp-config.php lost the line that loads wp-settings.php, so it was thrown away.' ); } /* * Not an equality check on purpose. A restored line is expected to be a * define() of ours again, but the owner may have edited it while it sat * commented out, and that is their business. What must never happen is * losing one that was already live. */ if ( count( $scan['defines'] ) < $live ) { return new WP_Error( 'dlogr_config_define_lost', 'A declaration that was active before the edit is gone from the result, so it was thrown away.' ); } return true; } /** * Everything that has to change in the file, as a list of splices. * * One list from one scan, applied later from the end backwards. Editing the * text as we go instead would move every offset behind the edit, and the * offsets are the only thing keeping this operation honest. * * @param array $state Result of analyse(). * @param string $src Config file contents. * @param string $block Block text as it will appear in the file. * @return array|WP_Error Keys list and disabled, the latter a line count. */ private static function edits( array $state, $src, $block ) { $list = array(); $disabled = 0; $shared = array(); foreach ( $state['defines'] as $define ) { if ( null === $define['offset_end'] ) { return new WP_Error( 'dlogr_config_open_statement', sprintf( 'The declaration of %1$s on line %2$d has no end, so wp-config.php was left alone.', $define['name'], $define['line_start'] ) ); } if ( ! $define['alone'] ) { $shared[] = $define['line_start']; continue; } $span = $define['span']; $text = substr( $src, $span['start'], $span['end'] - $span['start'] ); $list[] = array( 'start' => $span['start'], 'end' => $span['end'], 'replacement' => self::comment_out( $text ), ); $disabled += substr_count( $text, "\n" ) + 1; } if ( ! empty( $shared ) ) { /* * ⚠️ The one case the tokenizer cannot save us from. Commenting out * a line that also carries somebody else's statement produces a * perfectly valid file with half of their config silently gone. * Refusing is the only honest answer; the fragment on the page * tells the person what to do by hand. */ return new WP_Error( 'dlogr_config_shared_line', sprintf( 'On these lines a managed constant shares the line with other code, and commenting the line out would take that code with it: %s. wp-config.php was left alone; the block can be inserted by hand.', implode( ', ', $shared ) ) ); } $list[] = self::insertion( $state, $src, $block ); /* * An explanation left over from an earlier removal goes now: the line * it stood over is being commented out again, this time as an ordinary * disabled one, and the paragraph above it would then name a marker * that is no longer there. */ $list = array_merge( $list, self::stale_notes( $state, $src ) ); return array( 'list' => $list, 'disabled' => $disabled, ); } /** * The whole lines a statement occupies, or null when it shares them. * * Sharing means anything other than whitespace before the statement on its * first line, or anything but whitespace and a line comment after it on the * last one. A block comment opening there counts as sharing too: prefixing * that line would break the opening and leave its closing behind as code. * * @param string $src Config file contents. * @param int $start Byte offset the statement starts at. * @param int $end Byte offset just past its semicolon. * @return array|null Keys start and end, whole lines, excluding the line break. */ private static function line_span( $src, $start, $end ) { $break = strrpos( substr( $src, 0, $start ), "\n" ); $first = ( false === $break ) ? 0 : $break + 1; if ( 1 !== preg_match( '/^[\t ]*$/', substr( $src, $first, $start - $first ) ) ) { return null; } $break = strpos( $src, "\n", $end ); $last = ( false === $break ) ? strlen( $src ) : $break; $tail = rtrim( substr( $src, $end, $last - $end ), "\r" ); if ( 1 !== preg_match( '/^[\t ]*$/', $tail ) && 1 !== preg_match( '#^[\t ]*(//|\#)#', $tail ) ) { return null; } return array( 'start' => $first, 'end' => $last, ); } /** * Puts the marker in front of every line of a statement. * * Every line, not just the first: a define() spread over four lines would * otherwise keep three of them as code and break the file. Indentation is * kept in front of the marker so the shape of the file survives, and the * marker itself sits on each line so that a person reading this config in * two years, with the plugin long gone, can tell what happened to it. * * @param string $text Whole lines holding the statement. * @return string The same lines, commented out. */ private static function comment_out( $text ) { $lines = explode( "\n", $text ); foreach ( $lines as $key => $line ) { $lines[ $key ] = preg_replace( '/^([\t ]*)/', '$1// ' . self::MARKER_DISABLED . ' ', $line, 1 ); } return implode( "\n", $lines ); } /** * Where the block goes and what goes around it. * * At the start of the line anchor() found, followed by one blank line. * block_span() takes exactly that back out, so an insert and a removal * leave the file byte for byte as it was (заказчик, 2026-10-16). * * @param array $state Result of analyse(). * @param string $src Config file contents. * @param string $block Block text. * @return array One entry of the edit list. */ private static function insertion( array $state, $src, $block ) { $offset = $state['anchor']['offset']; $eol = $state['eol']; return array( 'start' => $offset, 'end' => $offset, 'replacement' => $block . $eol . $eol, ); } /** * What removing a block takes out: the mirror of insertion(). * * The block's lines with their line break, and one blank line under it - * the one insertion() put there. Blank lines above it are left alone: they * were there before the block (заказчик, 2026-10-16; until then removal * took the blank lines above instead, which left an extra one behind * wherever the line above the block was not blank). * * @param string $src Config file contents. * @param array $block One entry of the blocks list, closed. * @return array Keys start and end. */ private static function block_span( $src, array $block ) { $from = self::line_start( $src, $block['offset_start'] ); $start = 1 === preg_match( '/^[\t ]*$/', substr( $src, $from, $block['offset_start'] - $from ) ) ? $from : $block['offset_start']; $end = self::forward_over_eol( $src, $block['offset_end'] ); if ( 1 === preg_match( '/^[\t ]*\r?\n/', substr( $src, $end ), $match ) ) { $end += strlen( $match[0] ); } return array( 'start' => $start, 'end' => $end, ); } /** * Offset of the start of the line the given offset is on. * * @param string $src Config file contents. * @param int $offset Byte offset. * @return int */ private static function line_start( $src, $offset ) { $break = strrpos( substr( $src, 0, $offset ), "\n" ); return ( false === $break ) ? 0 : $break + 1; } /** * Extends a start offset back over blank lines directly above it. * * Without this an add, remove, add cycle would leave one more blank line * behind every time. * * @param string $src Config file contents. * @param int $start Offset to walk back from. * @return int */ private static function back_over_blank( $src, $start ) { $at = $start; while ( $at > 0 ) { $break = strrpos( substr( $src, 0, $at ), "\n" ); $from = ( false === $break ) ? 0 : $break + 1; if ( 1 !== preg_match( '/^[\t\r ]*$/', substr( $src, $from, $at - $from ) ) ) { break; } $at = $from; if ( 0 === $at ) { break; } --$at; } return $at === $start ? $start : $at + 1; } /** * Extends an end offset past the line break that follows it. * * @param string $src Config file contents. * @param int $end Offset to walk forward from. * @return int */ private static function forward_over_eol( $src, $end ) { if ( "\r\n" === substr( $src, $end, 2 ) ) { return $end + 2; } if ( "\n" === substr( $src, $end, 1 ) ) { return $end + 1; } return $end; } /** * Applies the splices from the end of the file backwards. * * @param string $src Config file contents. * @param array $edits Edits collected by edits(). * @return string The finished text. */ private static function apply( $src, array $edits ) { usort( $edits, function ( $left, $right ) { return $right['start'] - $left['start']; } ); foreach ( $edits as $edit ) { $src = substr( $src, 0, $edit['start'] ) . $edit['replacement'] . substr( $src, $edit['end'] ); } return $src; } /** * Reads back what we have just built, before any of it reaches the disk. * * ⚠️ By parsing, never by running it. Executing a config opens a database * connection and defines constants. * * @param string $content The finished text. * @param int $disabled How many lines were commented out. * @return true|WP_Error */ private static function verify( $content, $disabled ) { try { $scan = self::scan( $content ); } catch ( ParseError $e ) { return new WP_Error( 'dlogr_config_would_break', sprintf( 'The edited wp-config.php would not parse (%s), so it was thrown away and the file left alone.', $e->getMessage() ) ); } if ( 1 !== count( $scan['blocks'] ) || ! $scan['blocks'][0]['closed'] ) { return new WP_Error( 'dlogr_config_block_count', 'The edited wp-config.php did not end up with exactly one complete block, so it was thrown away.' ); } if ( 0 === $scan['require'] ) { return new WP_Error( 'dlogr_config_no_require', 'The edited wp-config.php lost the line that loads wp-settings.php, so it was thrown away.' ); } /* * The whole point of the operation: a live top-level define() of one of * our constants left above the block would win, PHP keeps the first one * and ignores ours - silently, which is the worst way to fail. */ if ( ! empty( $scan['defines'] ) ) { return new WP_Error( 'dlogr_config_define_left', 'A declaration of one of the managed constants survived the edit, so the result was thrown away.' ); } if ( substr_count( $content, self::MARKER_DISABLED ) < $disabled ) { return new WP_Error( 'dlogr_config_marker_count', 'Fewer lines came out marked than were commented out, so the result was thrown away.' ); } return true; } /** * Puts the finished text in place of the config, atomically. * * ⚠️ Through a temporary file in the same directory and a native rename(). * WP_Filesystem::move() deletes the target before renaming on the direct * transport, and a request arriving in that gap finds no wp-config.php at * all - it would be sent to the WordPress installer. * * ⚠️ The replacement gets the permissions, owner and group of the original * before the original is replaced (2026-10-04). rename() carries the file * it moves, not the one it replaces, so a temporary file born with the * permissions of the process umask turned a 640 config into 644 on the * test server - readable by every account on the machine, password and all. * Setting them on the temporary file, while it is still empty, leaves no * moment at which either file holds the content with the wrong ones. * * ⚠️ A config closed for writing is refused here as well (2026-10-04). The * rename needs write access to the directory only; the file's own * permissions do not enter into it, so a config its owner made read-only * would be replaced all the same. The callers check this before they start; * this is the last line, for any caller that does not. * * @param string $path Config to replace. * @param string $content The finished text. * @return true|WP_Error */ private static function swap( $path, $content ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Same reason as the write below; and a wrong "writable" here fails safe, because the write then fails on its own. if ( ! is_writable( $path ) ) { return new WP_Error( 'dlogr_config_read_only', 'wp-config.php is closed for writing, and this plugin does not open it, so it was left untouched.' ); } $access = self::access_of( $path ); if ( is_wp_error( $access ) ) { return $access; } $temp = $path . '.dlogr-tmp'; $made = self::create_like( $temp, $access ); if ( is_wp_error( $made ) ) { return $made; } // phpcs:ignore WordPress.WP.AlternativeFunctions -- The direct method has been established by the caller; WP_Filesystem would ask for credentials on a page that has already decided it does not need them. $written = file_put_contents( $temp, $content, LOCK_EX ); if ( false === $written || strlen( $content ) !== $written ) { if ( file_exists( $temp ) ) { wp_delete_file( $temp ); } return new WP_Error( 'dlogr_config_write_failed', 'The new wp-config.php could not be written next to the old one, which was left untouched.' ); } // Writing into an existing file keeps its permissions; checked rather than trusted. $kept = self::match_access( $temp, $access ); if ( is_wp_error( $kept ) ) { wp_delete_file( $temp ); return $kept; } // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- The atomic swap is the point; see the docblock. if ( ! rename( $temp, $path ) ) { wp_delete_file( $temp ); return new WP_Error( 'dlogr_config_rename_failed', 'The new wp-config.php could not take the place of the old one, which was left untouched.' ); } /* * Under an opcode cache the old config can otherwise stay compiled in * memory, and the block would appear to have been ignored. */ if ( function_exists( 'opcache_invalidate' ) ) { opcache_invalidate( $path, true ); } return true; } /** * Permissions, owner and group of a file, for a replacement to copy. * * The replacement holds the same password as the original, so it has to * end up exactly as closed as the original was (2026-10-04). * * @param string $path File to read them from. * @return array|WP_Error Keys perms (the 0777 bits), owner and group. */ public static function access_of( $path ) { clearstatcache( true, $path ); $perms = fileperms( $path ); $owner = fileowner( $path ); $group = filegroup( $path ); if ( false === $perms || false === $owner || false === $group ) { return new WP_Error( 'dlogr_config_access_unknown', 'The permissions of wp-config.php could not be read, so nothing was changed.' ); } return array( 'perms' => $perms & 0777, 'owner' => $owner, 'group' => $group, ); } /** * Creates an empty file that already has the given access. * * Empty on purpose: the access is settled before anything secret is put * into the file, so there is no moment at which a copy of the config sits * on disk readable by more people than the original. * * A leftover under the same name - from a request that died halfway - is * removed first. Then fopen( 'x' ), which creates the file or fails: it * never opens what is already there, so it cannot be led through a link * planted at that name into writing the config somewhere else. * * @param string $path File to create. * @param array $access Result of access_of(). * @return true|WP_Error On failure nothing is left behind. */ public static function create_like( $path, array $access ) { if ( file_exists( $path ) || is_link( $path ) ) { wp_delete_file( $path ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- Mode x has no WP_Filesystem equivalent, and its failure is the answer, reported below. $handle = @fopen( $path, 'x' ); if ( false === $handle ) { return new WP_Error( 'dlogr_config_temp_failed', 'No temporary file could be created next to wp-config.php, so nothing was changed. Check the permissions on that directory.' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Pairs with the fopen() above. fclose( $handle ); $matched = self::match_access( $path, $access ); if ( is_wp_error( $matched ) ) { wp_delete_file( $path ); } return $matched; } /** * Gives a file the permissions and group of another, then checks all three. * * The owner cannot be changed by anybody but root, so it is only checked: * the direct method makes sure new files belong to whoever owns WordPress, * not to whoever owns wp-config.php, and the two can differ. The group can * be changed to any group the process belongs to, so that is attempted. * * ⚠️ A mismatch is a refusal, not a note in the journal (заказчик, * 2026-10-04). Everything here happens before the rename, so refusing costs * nothing: the original is still in place, untouched. * * @param string $path File to adjust. * @param array $access Result of access_of(). * @return true|WP_Error */ public static function match_access( $path, array $access ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Same reason as the writes in this class: the direct method is established, WP_Filesystem would only add a credentials prompt. chmod( $path, $access['perms'] ); clearstatcache( true, $path ); if ( filegroup( $path ) !== $access['group'] ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chgrp, WordPress.PHP.NoSilencedErrors.Discouraged -- Same reason; a refusal is expected when the group is not ours, and the check below reports it. @chgrp( $path, $access['group'] ); clearstatcache( true, $path ); } $perms = fileperms( $path ); if ( false === $perms || ( $perms & 0777 ) !== $access['perms'] || fileowner( $path ) !== $access['owner'] || filegroup( $path ) !== $access['group'] ) { return new WP_Error( 'dlogr_config_access_mismatch', sprintf( 'The new file could not be given the permissions, owner and group of wp-config.php (%1$o, %2$d:%3$d), so nothing was changed.', $access['perms'], $access['owner'], $access['group'] ) ); } return true; } /** * Conditions under which the file is not touched at all. * * @param array $state Result of analyse(). * @param string $src Config file contents. * @return true|WP_Error */ private static function refuse( array $state, $src ) { if ( '' !== $state['parse_error'] ) { return new WP_Error( 'dlogr_config_parse_error', 'wp-config.php did not parse, so what is in it is unknown and nothing will be changed.' ); } if ( 0 === $state['anchor']['type'] ) { return new WP_Error( 'dlogr_config_no_anchor', 'No line in wp-config.php loads wp-settings.php, so there is no safe place to insert the block.' ); } if ( 3 === $state['anchor']['type'] ) { return new WP_Error( 'dlogr_config_anchor_shared', sprintf( 'The line that loads wp-settings.php (line %d) has other code in front of it, so the block could not be taken out again cleanly. wp-config.php was left alone.', $state['anchor']['line'] ) ); } foreach ( $state['blocks'] as $block ) { if ( ! $block['closed'] ) { return new WP_Error( 'dlogr_config_unclosed_block', 'A block of this plugin in wp-config.php has no closing marker. It has to be repaired by hand first.' ); } } if ( '' === $src ) { return new WP_Error( 'dlogr_config_empty', 'wp-config.php came back empty, which it never is. Nothing was changed.' ); } return true; } /** * Walks the tokens once, collecting blocks, defines, conflicts and anchor. * * @param string $src Config file contents. * @return array Five keys of the read() structure. * @throws ParseError When the file does not parse. */ private static function scan( $src ) { $tokens = token_get_all( $src, TOKEN_PARSE ); $blocks = array(); $defines = array(); $conflicts = array(); $disabled = array(); $kept = array(); $notes = array(); $comments = array(); $require = null; $depth = 0; $offset = 0; $line = 1; $prev = null; $prev2 = null; $open = null; foreach ( $tokens as $i => $token ) { $text = is_array( $token ) ? $token[1] : $token; /* * Curly depth counts interpolation braces too. An interpolated * "{$a}" opens with T_CURLY_OPEN and closes with a plain brace: * counting only the plain ones would drive the depth negative and * make a nested define() look top-level. */ if ( is_array( $token ) ) { if ( T_CURLY_OPEN === $token[0] || T_DOLLAR_OPEN_CURLY_BRACES === $token[0] ) { ++$depth; } } elseif ( '{' === $token ) { ++$depth; } elseif ( '}' === $token ) { --$depth; } if ( is_array( $token ) && ( T_COMMENT === $token[0] || T_DOC_COMMENT === $token[0] ) ) { $comments[] = array( 'offset' => $offset, 'text' => $token[1], ); if ( self::opens_with( $token[1], self::MARKER_DISABLED ) ) { $disabled[] = array( 'line' => $line, 'offset_start' => $offset, 'offset_end' => $offset + strlen( $token[1] ), 'text' => $token[1], ); } if ( self::opens_with( $token[1], self::MARKER_KEPT ) ) { $kept[] = array( 'line' => $line, 'offset_start' => $offset, 'text' => $token[1], ); } if ( self::opens_with( $token[1], self::MARKER_NOTE ) ) { $notes[] = array( 'line' => $line, 'offset_start' => $offset, 'offset_end' => $offset + strlen( $token[1] ), ); } if ( self::opens_with( $token[1], self::MARKER_BEGIN ) ) { if ( null !== $open ) { $blocks[] = $open; } $version = ''; if ( preg_match( '/' . preg_quote( self::MARKER_BEGIN, '/' ) . '\s+(v\d+)/', $token[1], $match ) ) { $version = $match[1]; } $open = array( 'version' => $version, 'line_start' => $line, 'line_end' => null, 'offset_start' => $offset, 'offset_end' => null, 'closed' => false, ); } elseif ( self::opens_with( $token[1], self::MARKER_END ) ) { $closing = array( 'version' => '', 'line_start' => null, 'line_end' => $line + substr_count( $token[1], "\n" ), 'offset_start' => null, 'offset_end' => $offset + strlen( $token[1] ), 'closed' => true, ); if ( null !== $open ) { $closing['version'] = $open['version']; $closing['line_start'] = $open['line_start']; $closing['offset_start'] = $open['offset_start']; $open = null; } $blocks[] = $closing; } } /* * The anchor: the statement that loads wp-settings.php. A bare * T_REQUIRE_ONCE is not enough - a config may include other files - * so the statement itself has to mention wp-settings.php. The first * one counts; a second is either a harmless repeat or a branch of an * if, and a block in the wrong branch shows as not_loaded (заказчик, * 2026-10-16). */ if ( null === $require && is_array( $token ) && ( T_REQUIRE_ONCE === $token[0] || T_REQUIRE === $token[0] ) && self::statement_mentions( $tokens, $i, self::ANCHOR_REQUIRE ) ) { $require = array( 'offset' => $offset, 'line' => $line, ); } if ( self::is_define( $token ) ) { $name = self::constant_name( $tokens, $i ); $top = self::is_top_level( $depth, $prev, $prev2 ); $bound = self::statement_end( $tokens, $i, $offset, $line ); if ( null === $name ) { /* * define( $name, ... ) - the name is computed, so it cannot * be told whether it is one of ours, and a line whose * effect is unknown does not get commented out blind. * Recorded, not acted upon. */ if ( $top ) { $conflicts[] = array( 'name' => '', 'line' => $line, 'reason' => 'dynamic', ); } } elseif ( in_array( $name, self::MANAGED, true ) ) { if ( $top ) { /* * On PHP 7.4 a leading namespace separator is its own * token in front of this one, so the offset would start * past it and leave the backslash outside whatever the * writer wraps around the statement - a ParseError on a * live config. PHP 8 folds it into the same token and * needs no adjustment. */ $start = $offset; if ( is_array( $prev ) && T_NS_SEPARATOR === $prev[0] ) { $start -= strlen( $prev[1] ); } $defines[] = array( 'name' => $name, 'line_start' => $line, 'line_end' => null === $bound ? $line : $bound['line_end'], 'offset_start' => $start, 'offset_end' => null === $bound ? null : $bound['offset_end'], 'position' => 'after', ); } else { /* * Nested declarations stay untouched: inside an if, a * function or a class this is somebody's logic, not a * setting. Commenting out the body of a braceless if * would leave the if dangling - a ParseError, a white * screen on the whole site, admin area included. */ $conflicts[] = array( 'name' => $name, 'line' => $line, 'reason' => 'nested', ); } } } $offset += strlen( $text ); $line += substr_count( $text, "\n" ); if ( is_array( $token ) && in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { continue; } $prev2 = $prev; $prev = $token; } if ( null !== $open ) { $blocks[] = $open; } return array( 'blocks' => $blocks, 'defines' => $defines, 'conflicts' => $conflicts, 'disabled' => $disabled, 'kept' => $kept, 'notes' => $notes, 'anchor' => self::anchor( $src, $require, $comments ), 'require' => null === $require ? 0 : $require['line'], ); } /** * Where the block goes: in front of the line that loads wp-settings.php, * above the comments sitting right on top of it (заказчик, 2026-10-15 * and 2026-10-16). * * That line uses ABSPATH itself, so whatever loads the config has defined * it by then - and between the block and that line there is nothing but * comments, so the block can count on ABSPATH too. Above the comments, not * between: in the stock config the line carries its own comment, and a * block wedged in between would read as what that comment describes. * * The climb stops at a blank line, at code, at a comment sharing its line * with anything - the opening tag included - and at a comment of ours. * * ⚠️ When the line has other code in front of it, the answer is no place * at all, and the block is pasted by hand. Inserted there it would work, * but no removal could take it back out without leaving the line changed - * the same reason a define() sharing its line is not taken over. * * @param string $src Config file contents. * @param array|null $statement First statement loading wp-settings.php: offset, line. * @param array $comments Comment tokens in file order: offset, text. * @return array Keys type, offset, line - see read(). */ private static function anchor( $src, $statement, array $comments ) { $none = array( 'type' => 0, 'offset' => 0, 'line' => 0, ); if ( null === $statement ) { return $none; } $at = self::line_start( $src, $statement['offset'] ); if ( 1 !== preg_match( '/^[\t ]*$/', substr( $src, $at, $statement['offset'] - $at ) ) ) { $none['type'] = 3; $none['line'] = $statement['line']; return $none; } for ( $k = count( $comments ) - 1; $k >= 0; $k-- ) { $comment = $comments[ $k ]; if ( $comment['offset'] >= $at ) { continue; } /* * Trimmed, because on PHP 7.4 a double-slash comment carries its * line break and on PHP 8 it does not. Either way exactly one line * break may stand between the comment and the line below it: two * mean a blank line, and the comment belongs to something else. */ $end = $comment['offset'] + strlen( rtrim( $comment['text'], "\r\n" ) ); $from = self::line_start( $src, $comment['offset'] ); if ( 1 !== preg_match( '/^[\t ]*\r?\n\z/', substr( $src, $end, $at - $end ) ) || 1 !== preg_match( '/^[\t ]*$/', substr( $src, $from, $comment['offset'] - $from ) ) || self::is_ours( $comment['text'] ) ) { break; } $at = $from; } return array( 'type' => 2, 'offset' => $at, 'line' => substr_count( substr( $src, 0, $at ), "\n" ) + 1, ); } /** * Whether a comment token begins with any marker of this plugin. * * @param string $text Comment token. * @return bool */ private static function is_ours( $text ) { $markers = array( self::MARKER_BEGIN, self::MARKER_END, self::MARKER_DISABLED, self::MARKER_KEPT, self::MARKER_NOTE, ); foreach ( $markers as $marker ) { if ( self::opens_with( $text, $marker ) ) { return true; } } return false; } /** * Whether a comment token begins with one of our markers. * * ⚠️ Begins with, not contains anywhere - and that is the whole point of * this method. A comment that merely mentions the marker in prose, say a * note reading "removed BEGIN Debug Log Router by hand on 12 March, ticket * 431", would otherwise be taken for the start of our block. On insertion * that means a false "already there"; on removal it means deleting from * somebody else's line onwards, which is far worse. * * The plugin name inside the marker already keeps other plugins' BEGIN/END * blocks - a widespread convention - from matching; this closes the * remaining hole, which is our own name written in a sentence. * * @param string $text Comment token, including its opening slashes. * @param string $marker Marker to look for. * @return bool */ private static function opens_with( $text, $marker ) { $pattern = '#^(?:/\*+|//|\#)\s*' . preg_quote( $marker, '#' ) . '\b#'; return 1 === preg_match( $pattern, $text ); } /** * Whether a token is a call to define(). * * PHP 8 folds a fully qualified define into a single T_NAME_FULLY_QUALIFIED * token, PHP 7.4 splits it into T_NS_SEPARATOR plus T_STRING. Both are * handled, otherwise the plugin would behave differently depending on the * site's PHP version - and the version that missed it would lose the first * define() silently. Verified on 7.4.30 and 8.5. * * @param array|string $token Token from token_get_all(). * @return bool */ private static function is_define( $token ) { if ( ! is_array( $token ) ) { return false; } if ( T_STRING === $token[0] && 'define' === strtolower( $token[1] ) ) { return true; } if ( defined( 'T_NAME_FULLY_QUALIFIED' ) && constant( 'T_NAME_FULLY_QUALIFIED' ) === $token[0] && '\define' === strtolower( $token[1] ) ) { return true; } return false; } /** * Whether this define() is a bare top-level declaration. * * Two conditions, and neither is redundant - each one alone lets a real * case through: * * - curly depth 0 rejects bodies of ifs, functions and classes, but * accepts a braceless if followed by a define on the next line; * - the previous significant token rejects that braceless if, but accepts * a define inside an if that does have braces, where the previous token * is the opening brace. * * The second one is what keeps a dangling if from ever being created. * Verified against both cases on 7.4.30 and 8.5. * * @param int $depth Curly brace depth at the token. * @param array|string $prev Previous significant token. * @param array|string $prev2 The one before it, for a fully qualified define on PHP 7.4. * @return bool */ private static function is_top_level( $depth, $prev, $prev2 ) { if ( 0 !== $depth ) { return false; } $anchor = $prev; if ( is_array( $prev ) && T_NS_SEPARATOR === $prev[0] ) { $anchor = $prev2; } if ( null === $anchor ) { return true; } if ( is_array( $anchor ) ) { return T_OPEN_TAG === $anchor[0]; } return in_array( $anchor, array( ';', '{', '}' ), true ); } /** * Reads the constant name out of a define() call. * * Only a plain string literal in first position counts. Anything else - * a variable, a concatenation, another constant - means the name is not * known at read time. * * @param array $tokens All tokens. * @param int $i Index of the define token. * @return string|null Constant name, or null when it is not a literal. */ private static function constant_name( $tokens, $i ) { $seen_paren = false; $count = count( $tokens ); for ( $j = $i + 1; $j < $count; $j++ ) { $token = $tokens[ $j ]; if ( is_array( $token ) && in_array( $token[0], array( T_WHITESPACE, T_COMMENT, T_DOC_COMMENT ), true ) ) { continue; } if ( ! $seen_paren ) { if ( ! is_array( $token ) && '(' === $token ) { $seen_paren = true; continue; } return null; } if ( is_array( $token ) && T_CONSTANT_ENCAPSED_STRING === $token[0] ) { $raw = substr( $token[1], 1, -1 ); return preg_match( '/^[A-Za-z_][A-Za-z0-9_]*$/', $raw ) ? $raw : null; } return null; } return null; } /** * Finds where the statement starting at $i ends. * * A declaration can span several lines, with the name on one and the value * on another. Commenting out only its first line would leave the rest as a * syntax error, so the whole range is needed. The semicolon is looked for * at parenthesis depth zero, past any nested calls in the arguments. * * @param array $tokens All tokens. * @param int $i Index of the define token. * @param int $offset Byte offset of that token. * @param int $line Line of that token. * @return array|null Keys line_end and offset_end, null when unterminated. */ private static function statement_end( $tokens, $i, $offset, $line ) { $paren = 0; $count = count( $tokens ); for ( $j = $i; $j < $count; $j++ ) { $token = $tokens[ $j ]; $text = is_array( $token ) ? $token[1] : $token; if ( ! is_array( $token ) ) { if ( '(' === $token ) { ++$paren; } elseif ( ')' === $token ) { --$paren; } } $offset += strlen( $text ); $line += substr_count( $text, "\n" ); if ( ! is_array( $token ) && ';' === $token && 0 === $paren ) { return array( 'line_end' => $line, 'offset_end' => $offset, ); } } return null; } /** * Whether the statement starting at $i mentions the given string literal. * * @param array $tokens All tokens. * @param int $i Index of the first token of the statement. * @param string $needle Substring to look for inside string literals. * @return bool */ private static function statement_mentions( $tokens, $i, $needle ) { $count = count( $tokens ); for ( $j = $i; $j < $count; $j++ ) { $token = $tokens[ $j ]; if ( ! is_array( $token ) ) { if ( ';' === $token ) { return false; } continue; } if ( T_CONSTANT_ENCAPSED_STRING === $token[0] && false !== strpos( $token[1], $needle ) ) { return true; } } return false; } /** * Dominant line ending of the file. * * Worth knowing before writing anything: the config of this very * installation is 104 CRLF and not a single lone LF, while our templates * are LF. Inserting one into the other gives a file with mixed endings - * PHP does not care, but a human reading it over FTP does, and diff starts * reporting lines nobody touched. * * @param string $src File contents. * @return string The CRLF or the LF sequence, whichever dominates. */ private static function eol( $src ) { $crlf = substr_count( $src, "\r\n" ); $lf = substr_count( $src, "\n" ) - $crlf; return $crlf > $lf ? "\r\n" : "\n"; } /** * One path spelling for comparing and for pasting into PHP source. * * Forward slashes work on Windows too, and a backslash inside a single * quoted PHP string is an escape character - the one thing that must never * reach wp-config.php by accident. * * @param string $path Path in whatever spelling it arrived. * @return string Path with forward slashes and no trailing one. */ private static function slashes( $path ) { return rtrim( str_replace( '\\', '/', $path ), '/' ); } /** * Makes a path safe to sit inside a single quoted PHP string. * * Paranoid on purpose: a stray quote here is a ParseError in wp-config.php, * which is a white screen on the whole site. Backslashes are gone by the * time this runs, so the quote is all that is left to handle. * * @param string $path Path with forward slashes. * @return string Path with quotes escaped. */ private static function quote( $path ) { return str_replace( "'", "\\'", $path ); } }