1<?php
2namespace Crossjoin\Css\Format\Rule\Style;
3
4use Crossjoin\Css\Format\Rule\DeclarationAbstract;
5use Crossjoin\Css\Format\Rule\RuleAbstract;
6use Crossjoin\Css\Format\Rule\RuleGroupableInterface;
7use Crossjoin\Css\Format\Rule\TraitDeclarations;
8use Crossjoin\Css\Format\StyleSheet\StyleSheet;
9use Crossjoin\Css\Helper\Placeholder;
10
11class StyleRuleSet
12extends RuleAbstract
13implements RuleGroupableInterface
14{
15    use TraitDeclarations;
16
17    /**
18     * @var StyleSelector[] Array of selectors for the style rule
19     */
20    protected $selectors = [];
21
22    /**
23     * @param string|null $ruleString
24     * @param StyleSheet $styleSheet
25     */
26    public function __construct($ruleString = null, StyleSheet $styleSheet = null)
27    {
28        if ($styleSheet !== null) {
29            $this->setStyleSheet($styleSheet);
30        }
31        if ($ruleString !== null) {
32            $ruleString = Placeholder::replaceStringsAndComments($ruleString);
33            $this->parseRuleString($ruleString);
34        }
35    }
36
37    /**
38     * Sets the selectors for the style rule.
39     *
40     * @param StyleSelector[]|StyleSelector $selectors
41     * @return $this
42     */
43    public function setSelectors($selectors)
44    {
45        $this->selectors = [];
46        if (!is_array($selectors)) {
47            $selectors = [$selectors];
48        }
49        foreach ($selectors as $selector) {
50            $this->addSelector($selector);
51        }
52
53        return $this;
54    }
55
56    /**
57     * Adds a selector for the style rule.
58     *
59     * @param StyleSelector $selector
60     * @return $this
61     */
62    public function addSelector(StyleSelector $selector)
63    {
64        $this->selectors[] = $selector;
65
66        return $this;
67    }
68
69    /**
70     * Gets the selectors for the style rule.
71     *
72     * @return StyleSelector[]
73     */
74    public function getSelectors()
75    {
76        return $this->selectors;
77    }
78
79    /**
80     * Adds a declaration to the rule.
81     *
82     * @param StyleDeclaration $declaration
83     * @return $this
84     */
85    public function addDeclaration(DeclarationAbstract $declaration)
86    {
87        if ($declaration instanceof StyleDeclaration) {
88            $this->declarations[] = $declaration;
89        } else {
90            throw new \InvalidArgumentException(
91                "Invalid declaration instance. Instance of 'StyleDeclaration' expected."
92            );
93        }
94
95        return $this;
96    }
97
98    /**
99     * Parses the selector rule.
100     *
101     * @param string $ruleString
102     */
103    protected function parseRuleString($ruleString)
104    {
105        foreach ($this->getSelectorStrings($ruleString) as $selectorString)
106        {
107            // Check for invalid selector (e.g. if starting with a comma, like in this example from
108            // the spec ",all { body { background:lime } }")
109            if ($selectorString === "") {
110                $this->setIsValid(false);
111                $this->addValidationError("Invalid selector at '$ruleString'.");
112                break;
113            }
114
115            $this->addSelector(new StyleSelector($selectorString, $this->getStyleSheet()));
116        }
117    }
118
119    /**
120     * Helper method to parse the style rule.
121     *
122     * @param string $selectorList
123     * @return array
124     */
125    protected function getSelectorStrings($selectorList)
126    {
127        $charset = $this->getCharset();
128
129        $groupsOpened = 0;
130        $ignoreGroupOpenCloseChar = false;
131        $enclosedChar = null;
132
133        $selectors = [];
134        $subSelectorList = "";
135        $currentSelectors = [""];
136        $currentSelectorKeys = [0];
137        $lastChar = null;
138
139        if (preg_match('/[^\x00-\x7f]/', $selectorList)) {
140            $isAscii = false;
141            $strLen  = mb_strlen($selectorList, $charset);
142        } else {
143            $isAscii = true;
144            $strLen = strlen($selectorList);
145        }
146
147        for ($i = 0, $j = $strLen; $i < $j; $i++) {
148            if ($isAscii === true) {
149                $char = $selectorList[$i];
150            } else {
151                $char = mb_substr($selectorList, $i, 1, $charset);
152            }
153
154            if ($char === "(") {
155                if ($groupsOpened > 0) {
156                    $subSelectorList .= $char;
157                } else {
158                    if ($ignoreGroupOpenCloseChar === false) {
159                        foreach ($currentSelectorKeys as $index) {
160                            $currentSelectors[$index] .= $char;
161                        }
162                    }
163                }
164                $groupsOpened++;
165            } else if ($char === ")") {
166                $groupsOpened--;
167                if ($groupsOpened > 0) {
168                    $subSelectorList .= $char;
169                } else {
170                    if ($subSelectorList != "") {
171                        $subSelectors = $this->getSelectorStrings($subSelectorList);
172                        $newSelectors = [];
173                        foreach ($subSelectors as $subSelector) {
174                            foreach ($currentSelectors as $currentSelector) {
175                                $concat = $lastChar === "(" ? "" : " ";
176                                $newSelectors[] = $currentSelector . $concat . $subSelector;
177                            }
178                        }
179                        $currentSelectors = $newSelectors;
180                        $currentSelectorKeys = array_keys($currentSelectors);
181                    }
182
183                    if ($ignoreGroupOpenCloseChar === false) {
184                        foreach ($currentSelectorKeys as $index) {
185                            $currentSelectors[$index] .= $char;
186                        }
187                    } else {
188                        $ignoreGroupOpenCloseChar = false;
189                    }
190                    $subSelectorList = "";
191                }
192            } else if ($char === ",") {
193                if ($groupsOpened > 0) {
194                    $subSelectorList .= $char;
195                } else {
196                    foreach ($currentSelectors as $currentSelector) {
197                        $selectors[] = trim($currentSelector, " \r\n\t\f");
198                    }
199                    $currentSelectors = [""];
200                    $currentSelectorKeys = [0];
201                }
202            } else if ($char === ":") {
203                if ($groupsOpened > 0) {
204                    $subSelectorList .= $char;
205                } else {
206                    if ($isAscii === true) {
207                        $nextChars = substr($selectorList, $i);
208                    } else {
209                        $nextChars = mb_substr($selectorList, $i, null, $charset);
210                    }
211                    $nextChars = strtolower(preg_replace('/^(\:[-a-z]+)[^-a-z]*.*/', '\\1', $nextChars));
212
213                    if ($nextChars === ":matches") {
214                        $i += (9 - 2);
215                        $ignoreGroupOpenCloseChar = true;
216                    } elseif ($nextChars === ":has") {
217                        $i += (5 - 2);
218                        $ignoreGroupOpenCloseChar = true;
219                    } else {
220                        foreach ($currentSelectorKeys as $index) {
221                            $currentSelectors[$index] .= $char;
222                        }
223                    }
224                }
225            } else {
226                if ($groupsOpened > 0) {
227                    $subSelectorList .= $char;
228                } else {
229                    foreach ($currentSelectorKeys as $index) {
230                        $currentSelectors[$index] .= $char;
231                    }
232                }
233            }
234
235            // Save last char (to avoid costly mb_substr() call)
236            $lastChar = $char;
237        }
238        foreach ($currentSelectors as $currentSelector) {
239            $selectors[] = trim($currentSelector, " \r\n\t\f");
240        }
241
242        return $selectors;
243    }
244}