1<?php
2
3namespace Sabberworm\CSS\CSSList;
4
5use Sabberworm\CSS\Comment\Commentable;
6use Sabberworm\CSS\Parsing\ParserState;
7use Sabberworm\CSS\Parsing\SourceException;
8use Sabberworm\CSS\Parsing\UnexpectedTokenException;
9use Sabberworm\CSS\Property\AtRule;
10use Sabberworm\CSS\Property\Charset;
11use Sabberworm\CSS\Property\CSSNamespace;
12use Sabberworm\CSS\Property\Import;
13use Sabberworm\CSS\Property\Selector;
14use Sabberworm\CSS\Renderable;
15use Sabberworm\CSS\RuleSet\AtRuleSet;
16use Sabberworm\CSS\RuleSet\DeclarationBlock;
17use Sabberworm\CSS\RuleSet\RuleSet;
18use Sabberworm\CSS\Value\CSSString;
19use Sabberworm\CSS\Value\URL;
20use Sabberworm\CSS\Value\Value;
21
22/**
23 * A CSSList is the most generic container available. Its contents include RuleSet as well as other CSSList objects.
24 * Also, it may contain Import and Charset objects stemming from @-rules.
25 */
26abstract class CSSList implements Renderable, Commentable {
27
28	protected $aComments;
29	protected $aContents;
30	protected $iLineNo;
31
32	public function __construct($iLineNo = 0) {
33		$this->aComments = array();
34		$this->aContents = array();
35		$this->iLineNo = $iLineNo;
36	}
37
38	public static function parseList(ParserState $oParserState, CSSList $oList) {
39		$bIsRoot = $oList instanceof Document;
40		if(is_string($oParserState)) {
41			$oParserState = new ParserState($oParserState);
42		}
43		$bLenientParsing = $oParserState->getSettings()->bLenientParsing;
44		while(!$oParserState->isEnd()) {
45			$comments = $oParserState->consumeWhiteSpace();
46			$oListItem = null;
47			if($bLenientParsing) {
48				try {
49					$oListItem = self::parseListItem($oParserState, $oList);
50				} catch (UnexpectedTokenException $e) {
51					$oListItem = false;
52				}
53			} else {
54				$oListItem = self::parseListItem($oParserState, $oList);
55			}
56			if($oListItem === null) {
57				// List parsing finished
58				return;
59			}
60			if($oListItem) {
61				$oListItem->setComments($comments);
62				$oList->append($oListItem);
63			}
64		}
65		if(!$bIsRoot && !$bLenientParsing) {
66			throw new SourceException("Unexpected end of document", $oParserState->currentLine());
67		}
68	}
69
70	private static function parseListItem(ParserState $oParserState, CSSList $oList) {
71		$bIsRoot = $oList instanceof Document;
72		if ($oParserState->comes('@')) {
73			$oAtRule = self::parseAtRule($oParserState);
74			if($oAtRule instanceof Charset) {
75				if(!$bIsRoot) {
76					throw new UnexpectedTokenException('@charset may only occur in root document', '', 'custom', $oParserState->currentLine());
77				}
78				if(count($oList->getContents()) > 0) {
79					throw new UnexpectedTokenException('@charset must be the first parseable token in a document', '', 'custom', $oParserState->currentLine());
80				}
81				$oParserState->setCharset($oAtRule->getCharset()->getString());
82			}
83			return $oAtRule;
84		} else if ($oParserState->comes('}')) {
85			$oParserState->consume('}');
86			if ($bIsRoot) {
87				if ($oParserState->getSettings()->bLenientParsing) {
88					while ($oParserState->comes('}')) $oParserState->consume('}');
89					return DeclarationBlock::parse($oParserState);
90				} else {
91					throw new SourceException("Unopened {", $oParserState->currentLine());
92				}
93			} else {
94				return null;
95			}
96		} else {
97			return DeclarationBlock::parse($oParserState);
98		}
99	}
100
101	private static function parseAtRule(ParserState $oParserState) {
102		$oParserState->consume('@');
103		$sIdentifier = $oParserState->parseIdentifier();
104		$iIdentifierLineNum = $oParserState->currentLine();
105		$oParserState->consumeWhiteSpace();
106		if ($sIdentifier === 'import') {
107			$oLocation = URL::parse($oParserState);
108			$oParserState->consumeWhiteSpace();
109			$sMediaQuery = null;
110			if (!$oParserState->comes(';')) {
111				$sMediaQuery = $oParserState->consumeUntil(';');
112			}
113			$oParserState->consume(';');
114			return new Import($oLocation, $sMediaQuery, $iIdentifierLineNum);
115		} else if ($sIdentifier === 'charset') {
116			$sCharset = CSSString::parse($oParserState);
117			$oParserState->consumeWhiteSpace();
118			$oParserState->consume(';');
119			return new Charset($sCharset, $iIdentifierLineNum);
120		} else if (self::identifierIs($sIdentifier, 'keyframes')) {
121			$oResult = new KeyFrame($iIdentifierLineNum);
122			$oResult->setVendorKeyFrame($sIdentifier);
123			$oResult->setAnimationName(trim($oParserState->consumeUntil('{', false, true)));
124			CSSList::parseList($oParserState, $oResult);
125			return $oResult;
126		} else if ($sIdentifier === 'namespace') {
127			$sPrefix = null;
128			$mUrl = Value::parsePrimitiveValue($oParserState);
129			if (!$oParserState->comes(';')) {
130				$sPrefix = $mUrl;
131				$mUrl = Value::parsePrimitiveValue($oParserState);
132			}
133			$oParserState->consume(';');
134			if ($sPrefix !== null && !is_string($sPrefix)) {
135				throw new UnexpectedTokenException('Wrong namespace prefix', $sPrefix, 'custom', $iIdentifierLineNum);
136			}
137			if (!($mUrl instanceof CSSString || $mUrl instanceof URL)) {
138				throw new UnexpectedTokenException('Wrong namespace url of invalid type', $mUrl, 'custom', $iIdentifierLineNum);
139			}
140			return new CSSNamespace($mUrl, $sPrefix, $iIdentifierLineNum);
141		} else {
142			//Unknown other at rule (font-face or such)
143			$sArgs = trim($oParserState->consumeUntil('{', false, true));
144			if (substr_count($sArgs, "(") != substr_count($sArgs, ")")) {
145				if($oParserState->getSettings()->bLenientParsing) {
146					return NULL;
147				} else {
148					throw new SourceException("Unmatched brace count in media query", $oParserState->currentLine());
149				}
150			}
151			$bUseRuleSet = true;
152			foreach(explode('/', AtRule::BLOCK_RULES) as $sBlockRuleName) {
153				if(self::identifierIs($sIdentifier, $sBlockRuleName)) {
154					$bUseRuleSet = false;
155					break;
156				}
157			}
158			if($bUseRuleSet) {
159				$oAtRule = new AtRuleSet($sIdentifier, $sArgs, $iIdentifierLineNum);
160				RuleSet::parseRuleSet($oParserState, $oAtRule);
161			} else {
162				$oAtRule = new AtRuleBlockList($sIdentifier, $sArgs, $iIdentifierLineNum);
163				CSSList::parseList($oParserState, $oAtRule);
164			}
165			return $oAtRule;
166		}
167	}
168
169		/**
170	 * Tests an identifier for a given value. Since identifiers are all keywords, they can be vendor-prefixed. We need to check for these versions too.
171	 */
172	private static function identifierIs($sIdentifier, $sMatch) {
173		return (strcasecmp($sIdentifier, $sMatch) === 0)
174			?: preg_match("/^(-\\w+-)?$sMatch$/i", $sIdentifier) === 1;
175	}
176
177
178	/**
179	 * @return int
180	 */
181	public function getLineNo() {
182		return $this->iLineNo;
183	}
184
185	/**
186	 * Prepend item to list of contents.
187	 *
188	 * @param object $oItem Item.
189	 */
190	public function prepend($oItem) {
191		array_unshift($this->aContents, $oItem);
192	}
193
194	/**
195	 * Append item to list of contents.
196	 *
197	 * @param object $oItem Item.
198	 */
199	public function append($oItem) {
200		$this->aContents[] = $oItem;
201	}
202
203	/**
204	 * Splice the list of contents.
205	 *
206	 * @param int       $iOffset      Offset.
207	 * @param int       $iLength      Length. Optional.
208	 * @param RuleSet[] $mReplacement Replacement. Optional.
209	 */
210	public function splice($iOffset, $iLength = null, $mReplacement = null) {
211		array_splice($this->aContents, $iOffset, $iLength, $mReplacement);
212	}
213
214    /**
215     * Insert an item before its sibling.
216     *
217     * @param mixed $oItem The item.
218     * @param mixed $oSibling The sibling.
219     */
220    public function insert($oItem, $oSibling) {
221        $iIndex = array_search($oSibling, $this->aContents);
222        if ($iIndex === false) {
223            return $this->append($oItem);
224        }
225        array_splice($this->aContents, $iIndex, 0, array($oItem));
226    }
227
228	/**
229	 * Removes an item from the CSS list.
230	 * @param RuleSet|Import|Charset|CSSList $oItemToRemove May be a RuleSet (most likely a DeclarationBlock), a Import, a Charset or another CSSList (most likely a MediaQuery)
231	 * @return bool Whether the item was removed.
232	 */
233	public function remove($oItemToRemove) {
234		$iKey = array_search($oItemToRemove, $this->aContents, true);
235		if ($iKey !== false) {
236			unset($this->aContents[$iKey]);
237			return true;
238		}
239		return false;
240	}
241
242	/**
243	 * Replaces an item from the CSS list.
244	 * @param RuleSet|Import|Charset|CSSList $oItemToRemove May be a RuleSet (most likely a DeclarationBlock), a Import, a Charset or another CSSList (most likely a MediaQuery)
245	 */
246	public function replace($oOldItem, $oNewItem) {
247		$iKey = array_search($oOldItem, $this->aContents, true);
248		if ($iKey !== false) {
249			array_splice($this->aContents, $iKey, 1, $oNewItem);
250			return true;
251		}
252		return false;
253	}
254
255	/**
256	 * Set the contents.
257	 * @param array $aContents Objects to set as content.
258	 */
259	public function setContents(array $aContents) {
260		$this->aContents = array();
261		foreach ($aContents as $content) {
262			$this->append($content);
263		}
264	}
265
266	/**
267	 * Removes a declaration block from the CSS list if it matches all given selectors.
268	 * @param array|string $mSelector The selectors to match.
269	 * @param boolean $bRemoveAll Whether to stop at the first declaration block found or remove all blocks
270	 */
271	public function removeDeclarationBlockBySelector($mSelector, $bRemoveAll = false) {
272		if ($mSelector instanceof DeclarationBlock) {
273			$mSelector = $mSelector->getSelectors();
274		}
275		if (!is_array($mSelector)) {
276			$mSelector = explode(',', $mSelector);
277		}
278		foreach ($mSelector as $iKey => &$mSel) {
279			if (!($mSel instanceof Selector)) {
280				$mSel = new Selector($mSel);
281			}
282		}
283		foreach ($this->aContents as $iKey => $mItem) {
284			if (!($mItem instanceof DeclarationBlock)) {
285				continue;
286			}
287			if ($mItem->getSelectors() == $mSelector) {
288				unset($this->aContents[$iKey]);
289				if (!$bRemoveAll) {
290					return;
291				}
292			}
293		}
294	}
295
296	public function __toString() {
297		return $this->render(new \Sabberworm\CSS\OutputFormat());
298	}
299
300	public function render(\Sabberworm\CSS\OutputFormat $oOutputFormat) {
301		$sResult = '';
302		$bIsFirst = true;
303		$oNextLevel = $oOutputFormat;
304		if(!$this->isRootList()) {
305			$oNextLevel = $oOutputFormat->nextLevel();
306		}
307		foreach ($this->aContents as $oContent) {
308			$sRendered = $oOutputFormat->safely(function() use ($oNextLevel, $oContent) {
309				return $oContent->render($oNextLevel);
310			});
311			if($sRendered === null) {
312				continue;
313			}
314			if($bIsFirst) {
315				$bIsFirst = false;
316				$sResult .= $oNextLevel->spaceBeforeBlocks();
317			} else {
318				$sResult .= $oNextLevel->spaceBetweenBlocks();
319			}
320			$sResult .= $sRendered;
321		}
322
323		if(!$bIsFirst) {
324			// Had some output
325			$sResult .= $oOutputFormat->spaceAfterBlocks();
326		}
327
328		return $sResult;
329	}
330
331	/**
332	* Return true if the list can not be further outdented. Only important when rendering.
333	*/
334	public abstract function isRootList();
335
336	public function getContents() {
337		return $this->aContents;
338	}
339
340	/**
341	 * @param array $aComments Array of comments.
342	 */
343	public function addComments(array $aComments) {
344		$this->aComments = array_merge($this->aComments, $aComments);
345	}
346
347	/**
348	 * @return array
349	 */
350	public function getComments() {
351		return $this->aComments;
352	}
353
354	/**
355	 * @param array $aComments Array containing Comment objects.
356	 */
357	public function setComments(array $aComments) {
358		$this->aComments = $aComments;
359	}
360
361}
362