summaryrefslogtreecommitdiffstats
path: root/devtools/server/actors/utils/walker-search.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/server/actors/utils/walker-search.js')
-rw-r--r--devtools/server/actors/utils/walker-search.js320
1 files changed, 320 insertions, 0 deletions
diff --git a/devtools/server/actors/utils/walker-search.js b/devtools/server/actors/utils/walker-search.js
new file mode 100644
index 0000000000..a5ffb48fad
--- /dev/null
+++ b/devtools/server/actors/utils/walker-search.js
@@ -0,0 +1,320 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+loader.lazyRequireGetter(
+ this,
+ "isWhitespaceTextNode",
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
+/**
+ * The walker-search module provides a simple API to index and search strings
+ * and elements inside a given document.
+ * It indexes tag names, attribute names and values, and text contents.
+ * It provides a simple search function that returns a list of nodes that
+ * matched.
+ */
+
+class WalkerIndex {
+ /**
+ * The WalkerIndex class indexes the document (and all subdocs) from
+ * a given walker.
+ *
+ * It is only indexed the first time the data is accessed and will be
+ * re-indexed if a mutation happens between requests.
+ *
+ * @param {Walker} walker The walker to be indexed
+ */
+ constructor(walker) {
+ this.walker = walker;
+ this.clearIndex = this.clearIndex.bind(this);
+
+ // Kill the index when mutations occur, the next data get will re-index.
+ this.walker.on("any-mutation", this.clearIndex);
+ }
+
+ /**
+ * Destroy this instance, releasing all data and references
+ */
+ destroy() {
+ this.walker.off("any-mutation", this.clearIndex);
+ }
+
+ clearIndex() {
+ if (!this.currentlyIndexing) {
+ this._data = null;
+ }
+ }
+
+ get doc() {
+ return this.walker.rootDoc;
+ }
+
+ /**
+ * Get the indexed data
+ * This getter also indexes if it hasn't been done yet or if the state is
+ * dirty
+ *
+ * @returns Map<String, Array<{type:String, node:DOMNode}>>
+ * A Map keyed on the searchable value, containing an array with
+ * objects containing the 'type' (one of ALL_RESULTS_TYPES), and
+ * the DOM Node.
+ */
+ get data() {
+ if (!this._data) {
+ this._data = new Map();
+ this.index();
+ }
+
+ return this._data;
+ }
+
+ _addToIndex(type, node, value) {
+ // Add an entry for this value if there isn't one
+ const entry = this._data.get(value);
+ if (!entry) {
+ this._data.set(value, []);
+ }
+
+ // Add the type/node to the list
+ this._data.get(value).push({
+ type,
+ node,
+ });
+ }
+
+ index() {
+ // Handle case where iterating nextNode() with the deepTreeWalker triggers
+ // a mutation (Bug 1222558)
+ this.currentlyIndexing = true;
+
+ const documentWalker = this.walker.getDocumentWalker(this.doc);
+ while (documentWalker.nextNode()) {
+ const node = documentWalker.currentNode;
+
+ if (
+ this.walker.targetActor.ignoreSubFrames &&
+ node.ownerDocument !== this.doc
+ ) {
+ continue;
+ }
+
+ if (node.nodeType === 1) {
+ // For each element node, we get the tagname and all attributes names
+ // and values
+ const localName = node.localName;
+ if (localName === "_moz_generated_content_marker") {
+ this._addToIndex("tag", node, "::marker");
+ this._addToIndex("text", node, node.textContent.trim());
+ } else if (localName === "_moz_generated_content_before") {
+ this._addToIndex("tag", node, "::before");
+ this._addToIndex("text", node, node.textContent.trim());
+ } else if (localName === "_moz_generated_content_after") {
+ this._addToIndex("tag", node, "::after");
+ this._addToIndex("text", node, node.textContent.trim());
+ } else {
+ this._addToIndex("tag", node, node.localName);
+ }
+
+ for (const { name, value } of node.attributes) {
+ this._addToIndex("attributeName", node, name);
+ this._addToIndex("attributeValue", node, value);
+ }
+ } else if (node.textContent && node.textContent.trim().length) {
+ // For comments and text nodes, we get the text
+ this._addToIndex("text", node, node.textContent.trim());
+ }
+ }
+
+ this.currentlyIndexing = false;
+ }
+}
+
+exports.WalkerIndex = WalkerIndex;
+
+class WalkerSearch {
+ /**
+ * The WalkerSearch class provides a way to search an indexed document as well
+ * as find elements that match a given css selector.
+ *
+ * Usage example:
+ * let s = new WalkerSearch(doc);
+ * let res = s.search("lang", index);
+ * for (let {matched, results} of res) {
+ * for (let {node, type} of results) {
+ * console.log("The query matched a node's " + type);
+ * console.log("Node that matched", node);
+ * }
+ * }
+ * s.destroy();
+ *
+ * @param {Walker} the walker to be searched
+ */
+ constructor(walker) {
+ this.walker = walker;
+ this.index = new WalkerIndex(this.walker);
+ }
+
+ destroy() {
+ this.index.destroy();
+ this.walker = null;
+ }
+
+ _addResult(node, type, results) {
+ if (!results.has(node)) {
+ results.set(node, []);
+ }
+
+ const matches = results.get(node);
+
+ // Do not add if the exact same result is already in the list
+ let isKnown = false;
+ for (const match of matches) {
+ if (match.type === type) {
+ isKnown = true;
+ break;
+ }
+ }
+
+ if (!isKnown) {
+ matches.push({ type });
+ }
+ }
+
+ _searchIndex(query, options, results) {
+ for (const [matched, res] of this.index.data) {
+ if (!options.searchMethod(query, matched)) {
+ continue;
+ }
+
+ // Add any relevant results (skipping non-requested options).
+ res
+ .filter(entry => {
+ return options.types.includes(entry.type);
+ })
+ .forEach(({ node, type }) => {
+ this._addResult(node, type, results);
+ });
+ }
+ }
+
+ _searchSelectors(query, options, results) {
+ // If the query is just one "word", no need to search because _searchIndex
+ // will lead the same results since it has access to tagnames anyway
+ const isSelector = query && query.match(/[ >~.#\[\]]/);
+ if (!options.types.includes("selector") || !isSelector) {
+ return;
+ }
+
+ const nodes = this.walker._multiFrameQuerySelectorAll(query);
+ for (const node of nodes) {
+ this._addResult(node, "selector", results);
+ }
+ }
+
+ _searchXPath(query, options, results) {
+ if (!options.types.includes("xpath")) {
+ return;
+ }
+
+ const nodes = this.walker._multiFrameXPath(query);
+ for (const node of nodes) {
+ // Exclude text nodes that only contain whitespace
+ // because they are not displayed in the Inspector.
+ if (!isWhitespaceTextNode(node)) {
+ this._addResult(node, "xpath", results);
+ }
+ }
+ }
+
+ /**
+ * Search the document
+ * @param {String} query What to search for
+ * @param {Object} options The following options are accepted:
+ * - searchMethod {String} one of WalkerSearch.SEARCH_METHOD_*
+ * defaults to WalkerSearch.SEARCH_METHOD_CONTAINS (does not apply to
+ * selector and XPath search types)
+ * - types {Array} a list of things to search for (tag, text, attributes, etc)
+ * defaults to WalkerSearch.ALL_RESULTS_TYPES
+ * @return {Array} An array is returned with each item being an object like:
+ * {
+ * node: <the dom node that matched>,
+ * type: <the type of match: one of WalkerSearch.ALL_RESULTS_TYPES>
+ * }
+ */
+ search(query, options = {}) {
+ options.searchMethod =
+ options.searchMethod || WalkerSearch.SEARCH_METHOD_CONTAINS;
+ options.types = options.types || WalkerSearch.ALL_RESULTS_TYPES;
+
+ // Empty strings will return no results, as will non-string input
+ if (typeof query !== "string") {
+ query = "";
+ }
+
+ // Store results in a map indexed by nodes to avoid duplicate results
+ const results = new Map();
+
+ // Search through the indexed data
+ this._searchIndex(query, options, results);
+
+ // Search with querySelectorAll
+ this._searchSelectors(query, options, results);
+
+ // Search with XPath
+ this._searchXPath(query, options, results);
+
+ // Concatenate all results into an Array to return
+ const resultList = [];
+ for (const [node, matches] of results) {
+ for (const { type } of matches) {
+ resultList.push({
+ node,
+ type,
+ });
+
+ // For now, just do one result per node since the frontend
+ // doesn't have a way to highlight each result individually
+ // yet.
+ break;
+ }
+ }
+
+ const documents = this.walker.targetActor.windows.map(win => win.document);
+
+ // Sort the resulting nodes by order of appearance in the DOM
+ resultList.sort((a, b) => {
+ // Disconnected nodes won't get good results from compareDocumentPosition
+ // so check the order of their document instead.
+ if (a.node.ownerDocument != b.node.ownerDocument) {
+ const indA = documents.indexOf(a.node.ownerDocument);
+ const indB = documents.indexOf(b.node.ownerDocument);
+ return indA - indB;
+ }
+ // If the same document, then sort on DOCUMENT_POSITION_FOLLOWING (4)
+ // which means B is after A.
+ return a.node.compareDocumentPosition(b.node) & 4 ? -1 : 1;
+ });
+
+ return resultList;
+ }
+}
+
+WalkerSearch.SEARCH_METHOD_CONTAINS = (query, candidate) => {
+ return query && candidate.toLowerCase().includes(query.toLowerCase());
+};
+
+WalkerSearch.ALL_RESULTS_TYPES = [
+ "tag",
+ "text",
+ "attributeName",
+ "attributeValue",
+ "selector",
+ "xpath",
+];
+
+exports.WalkerSearch = WalkerSearch;