Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
Keyword
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
3 / 3
28
100.00% covered (success)
100.00%
1 / 1
 indexOf
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
10
 split
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
9
 matchKeywordAt
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
9
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Db\Sql\Parser;
16
17/**
18 * Quote-aware keyword scanning class
19 *
20 * @category   Pop
21 * @package    Pop\Db
22 * @author     Nick Sagona, III <nick@popphp.org>
23 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    7.0.0
26 */
27class Keyword
28{
29
30    /**
31     * Find the position of the first occurrence of $needle in $haystack, ignoring any
32     * occurrence found inside a single-quoted, double-quoted or backtick-quoted span.
33     *
34     * @param  string $haystack
35     * @param  string $needle
36     * @param  int    $offset
37     * @param  bool   $caseInsensitive
38     * @return int|false
39     */
40    public static function indexOf(string $haystack, string $needle, int $offset = 0, bool $caseInsensitive = true): int|false
41    {
42        $length    = strlen($haystack);
43        $needleLen = strlen($needle);
44        $inQuote   = null;
45        $i         = $offset;
46
47        if ($needleLen === 0) {
48            return false;
49        }
50
51        while ($i <= ($length - $needleLen)) {
52            $char = $haystack[$i];
53
54            if ($inQuote !== null) {
55                if ($char === $inQuote) {
56                    $inQuote = null;
57                }
58                $i++;
59                continue;
60            }
61
62            if (($char === "'") || ($char === '"') || ($char === '`')) {
63                $inQuote = $char;
64                $i++;
65                continue;
66            }
67
68            $slice = substr($haystack, $i, $needleLen);
69            if ($caseInsensitive ? (strcasecmp($slice, $needle) === 0) : ($slice === $needle)) {
70                return $i;
71            }
72
73            $i++;
74        }
75
76        return false;
77    }
78
79    /**
80     * Split an expression string on AND/OR (case-sensitive, word-boundary-aware),
81     * ignoring matches inside a quoted span. Returns the same alternating
82     * [expr, 'AND'|'OR', expr, ...] shape as the preg_split call it replaces.
83     *
84     * @param  string $expression
85     * @return array
86     */
87    public static function split(string $expression): array
88    {
89        $tokens  = [];
90        $current = '';
91        $length  = strlen($expression);
92        $inQuote = null;
93        $i       = 0;
94
95        while ($i < $length) {
96            $char = $expression[$i];
97
98            if ($inQuote !== null) {
99                $current .= $char;
100                if ($char === $inQuote) {
101                    $inQuote = null;
102                }
103                $i++;
104                continue;
105            }
106
107            if (($char === "'") || ($char === '"') || ($char === '`')) {
108                $inQuote  = $char;
109                $current .= $char;
110                $i++;
111                continue;
112            }
113
114            $matched = self::matchKeywordAt($expression, $i, ['AND', 'OR']);
115
116            if ($matched !== null) {
117                $tokens[] = trim($current);
118                $tokens[] = $matched;
119                $current  = '';
120                $i       += strlen($matched);
121            } else {
122                $current .= $char;
123                $i++;
124            }
125        }
126
127        if (trim($current) !== '') {
128            $tokens[] = trim($current);
129        }
130
131        return array_values(array_filter($tokens, fn($token) => $token !== ''));
132    }
133
134    /**
135     * Determine if one of the given keywords matches exactly (case-sensitive) at the
136     * given position, bounded by non-alphanumeric/non-underscore characters on both sides.
137     *
138     * @param  string $expression
139     * @param  int    $position
140     * @param  array  $keywords
141     * @return ?string
142     */
143    protected static function matchKeywordAt(string $expression, int $position, array $keywords): ?string
144    {
145        foreach ($keywords as $keyword) {
146            $keywordLen = strlen($keyword);
147
148            if (substr($expression, $position, $keywordLen) !== $keyword) {
149                continue;
150            }
151
152            $before   = ($position > 0) ? $expression[$position - 1] : ' ';
153            $afterPos = $position + $keywordLen;
154            $after    = ($afterPos < strlen($expression)) ? $expression[$afterPos] : ' ';
155
156            $beforeIsBoundary = !ctype_alnum($before) && ($before !== '_');
157            $afterIsBoundary  = !ctype_alnum($after) && ($after !== '_');
158
159            if ($beforeIsBoundary && $afterIsBoundary) {
160                return $keyword;
161            }
162        }
163
164        return null;
165    }
166
167}