blob: 050370796bd0cbfed811ec977d6e7aee06f09658 [file] [log] [blame]
/// Query selector implementation for our DOM.
library html.src.query;
import 'package:csslib/parser.dart' as css;
import 'package:csslib/parser.dart' show TokenKind, Message;
import 'package:csslib/visitor.dart'; // the CSSOM
import 'package:html/dom.dart';
import 'package:html/src/constants.dart' show isWhitespaceCC;
bool matches(Node node, String selector) =>
SelectorEvaluator().matches(node, _parseSelectorList(selector));
Element querySelector(Node node, String selector) =>
SelectorEvaluator().querySelector(node, _parseSelectorList(selector));
List<Element> querySelectorAll(Node node, String selector) {
var results = <Element>[];
SelectorEvaluator()
.querySelectorAll(node, _parseSelectorList(selector), results);
return results;
}
// http://dev.w3.org/csswg/selectors-4/#grouping
SelectorGroup _parseSelectorList(String selector) {
var errors = <Message>[];
var group = css.parseSelectorGroup(selector, errors: errors);
if (group == null || errors.isNotEmpty) {
throw FormatException("'$selector' is not a valid selector: $errors");
}
return group;
}
class SelectorEvaluator extends Visitor {
/// The current HTML element to match against.
Element _element;
bool matches(Element element, SelectorGroup selector) {
_element = element;
return visitSelectorGroup(selector);
}
Element querySelector(Node root, SelectorGroup selector) {
for (var node in root.nodes) {
if (node is! Element) continue;
if (matches(node, selector)) return node;
var result = querySelector(node, selector);
if (result != null) return result;
}
return null;
}
void querySelectorAll(
Node root, SelectorGroup selector, List<Element> results) {
for (var node in root.nodes) {
if (node is! Element) continue;
if (matches(node, selector)) results.add(node);
querySelectorAll(node, selector, results);
}
}
bool visitSelectorGroup(SelectorGroup group) =>
group.selectors.any(visitSelector);
bool visitSelector(Selector selector) {
var old = _element;
var result = true;
// Note: evaluate selectors right-to-left as it's more efficient.
int combinator;
for (var s in selector.simpleSelectorSequences.reversed) {
if (combinator == null) {
result = s.simpleSelector.visit(this);
} else if (combinator == TokenKind.COMBINATOR_DESCENDANT) {
// descendant combinator
// http://dev.w3.org/csswg/selectors-4/#descendant-combinators
do {
_element = _element.parent;
} while (_element != null && !s.simpleSelector.visit(this));
if (_element == null) result = false;
} else if (combinator == TokenKind.COMBINATOR_TILDE) {
// Following-sibling combinator
// http://dev.w3.org/csswg/selectors-4/#general-sibling-combinators
do {
_element = _element.previousElementSibling;
} while (_element != null && !s.simpleSelector.visit(this));
if (_element == null) result = false;
}
if (!result) break;
switch (s.combinator) {
case TokenKind.COMBINATOR_PLUS:
// Next-sibling combinator
// http://dev.w3.org/csswg/selectors-4/#adjacent-sibling-combinators
_element = _element.previousElementSibling;
break;
case TokenKind.COMBINATOR_GREATER:
// Child combinator
// http://dev.w3.org/csswg/selectors-4/#child-combinators
_element = _element.parent;
break;
case TokenKind.COMBINATOR_DESCENDANT:
case TokenKind.COMBINATOR_TILDE:
// We need to iterate through all siblings or parents.
// For now, just remember what the combinator was.
combinator = s.combinator;
break;
case TokenKind.COMBINATOR_NONE:
break;
default:
throw _unsupported(selector);
}
if (_element == null) {
result = false;
break;
}
}
_element = old;
return result;
}
_unimplemented(SimpleSelector selector) =>
UnimplementedError("'$selector' selector of type "
"${selector.runtimeType} is not implemented");
_unsupported(selector) =>
FormatException("'$selector' is not a valid selector");
bool visitPseudoClassSelector(PseudoClassSelector selector) {
switch (selector.name) {
// http://dev.w3.org/csswg/selectors-4/#structural-pseudos
// http://dev.w3.org/csswg/selectors-4/#the-root-pseudo
case 'root':
// TODO(jmesserly): fix when we have a .ownerDocument pointer
// return _element == _element.ownerDocument.rootElement;
return _element.localName == 'html' && _element.parentNode == null;
// http://dev.w3.org/csswg/selectors-4/#the-empty-pseudo
case 'empty':
return _element.nodes
.any((n) => !(n is Element || n is Text && n.text.isNotEmpty));
// http://dev.w3.org/csswg/selectors-4/#the-blank-pseudo
case 'blank':
return _element.nodes.any((n) => !(n is Element ||
n is Text && n.text.runes.any((r) => !isWhitespaceCC(r))));
// http://dev.w3.org/csswg/selectors-4/#the-first-child-pseudo
case 'first-child':
return _element.previousElementSibling == null;
// http://dev.w3.org/csswg/selectors-4/#the-last-child-pseudo
case 'last-child':
return _element.nextElementSibling == null;
// http://dev.w3.org/csswg/selectors-4/#the-only-child-pseudo
case 'only-child':
return _element.previousElementSibling == null &&
_element.nextElementSibling == null;
// http://dev.w3.org/csswg/selectors-4/#link
case 'link':
return _element.attributes['href'] != null;
case 'visited':
// Always return false since we aren't a browser. This is allowed per:
// http://dev.w3.org/csswg/selectors-4/#visited-pseudo
return false;
}
// :before, :after, :first-letter/line can't match DOM elements.
if (_isLegacyPsuedoClass(selector.name)) return false;
throw _unimplemented(selector);
}
bool visitPseudoElementSelector(PseudoElementSelector selector) {
// :before, :after, :first-letter/line can't match DOM elements.
if (_isLegacyPsuedoClass(selector.name)) return false;
throw _unimplemented(selector);
}
static bool _isLegacyPsuedoClass(String name) {
switch (name) {
case 'before':
case 'after':
case 'first-line':
case 'first-letter':
return true;
default:
return false;
}
}
bool visitPseudoElementFunctionSelector(PseudoElementFunctionSelector s) =>
throw _unimplemented(s);
bool visitPseudoClassFunctionSelector(PseudoClassFunctionSelector selector) {
switch (selector.name) {
// http://dev.w3.org/csswg/selectors-4/#child-index
// http://dev.w3.org/csswg/selectors-4/#the-nth-child-pseudo
case 'nth-child':
// TODO(jmesserly): support An+B syntax too.
var exprs = selector.expression.expressions;
if (exprs.length == 1 && exprs[0] is LiteralTerm) {
LiteralTerm literal = exprs[0];
var parent = _element.parentNode;
return parent != null &&
literal.value > 0 &&
parent.nodes.indexOf(_element) == literal.value;
}
break;
// http://dev.w3.org/csswg/selectors-4/#the-lang-pseudo
case 'lang':
// TODO(jmesserly): shouldn't need to get the raw text here, but csslib
// gets confused by the "-" in the expression, such as in "es-AR".
var toMatch = selector.expression.span.text;
var lang = _getInheritedLanguage(_element);
// TODO(jmesserly): implement wildcards in level 4
return lang != null && lang.startsWith(toMatch);
}
throw _unimplemented(selector);
}
static String _getInheritedLanguage(Node node) {
while (node != null) {
var lang = node.attributes['lang'];
if (lang != null) return lang;
node = node.parent;
}
return null;
}
bool visitNamespaceSelector(NamespaceSelector selector) {
// Match element tag name
if (!selector.nameAsSimpleSelector.visit(this)) return false;
if (selector.isNamespaceWildcard) return true;
if (selector.namespace == '') return _element.namespaceUri == null;
throw _unimplemented(selector);
}
bool visitElementSelector(ElementSelector selector) =>
selector.isWildcard || _element.localName == selector.name.toLowerCase();
bool visitIdSelector(IdSelector selector) => _element.id == selector.name;
bool visitClassSelector(ClassSelector selector) =>
_element.classes.contains(selector.name);
// TODO(jmesserly): negation should support any selectors in level 4,
// not just simple selectors.
// http://dev.w3.org/csswg/selectors-4/#negation
bool visitNegationSelector(NegationSelector selector) =>
!selector.negationArg.visit(this);
bool visitAttributeSelector(AttributeSelector selector) {
// Match name first
var value = _element.attributes[selector.name.toLowerCase()];
if (value == null) return false;
if (selector.operatorKind == TokenKind.NO_MATCH) return true;
var select = '${selector.value}';
switch (selector.operatorKind) {
case TokenKind.EQUALS:
return value == select;
case TokenKind.INCLUDES:
return value.split(' ').any((v) => v.isNotEmpty && v == select);
case TokenKind.DASH_MATCH:
return value.startsWith(select) &&
(value.length == select.length || value[select.length] == '-');
case TokenKind.PREFIX_MATCH:
return value.startsWith(select);
case TokenKind.SUFFIX_MATCH:
return value.endsWith(select);
case TokenKind.SUBSTRING_MATCH:
return value.contains(select);
default:
throw _unsupported(selector);
}
}
}