Emmet: balance tag исправляем

Здравствуйте.
На страничке http://docs.emmet.io/actions/match-pair/ у Сергея написано следующее (перевод мой с английского):
********************************************************************************

Balance

“Balance Outward” (Ctrl+D)

“Balance Inward (Shift+Ctrl+D)

Известная балансировка тэгов: поиск границ тэгов и выбор их от текущей позиции каретки. Она может быть расширенной (outward balancing) или суженной (inward balancing) при вызове несколько раз подряд. Не каждый редактор поддерживает одновременно суженную и расширенную балансировку.

Балансировка тэгов Emmet достаточно уникальна. В отличие от остальных, она ищет границы тегов от текущей позиции каретки, а не от начала документа. Это означает, что она может работать не только в HTML документах.
********************************************************************************

Да, классная штука, правда, в реальной жизни нас скорее интересует текущий тег – просмотр границ и переход в начало конец этого тэга (для этого у Emmet‘а есть действие matching pair).

Но если она (балансировка) есть, хотелось бы использовать её на полную мощь. Так как я добавил поддержку Emmet для редактора AkelPad (http://akelpad.sourceforge.net/forum/viewtopic.php?p=30266#30266), мне захотелось подробнее разобраться в вопросе, а именно меня задели слова, что “Не каждый редактор поддерживает одновременно суженную и расширенную балансировку.“.

Экспериментируя с Emmet‘ом в AkelPad‘e, я заметил недоделанность эту (отсутствие балансировки вглубь, в частности), и решил посмотреть, что можно сделать.

Итак, нас интересует такой участок кода в файле emmet-app.js (я добавлял поддержку Emmet’а для AkelPad’а, используя плагин для Komodo Edit‘а, а потом проанализировал подобный файл от плагина к Notepad++):

/**
 * HTML pair matching (balancing) actions
 * @constructor
 * @memberOf __matchPairActionDefine
 * @param {Function} require
 * @param {Underscore} _
 */
emmet.exec(function(require, _) {
	/** @type emmet.actions */
	var actions = require('actions');
	var matcher = require('htmlMatcher');
	var lastMatch = null;
 
	/**
	 * Find and select HTML tag pair
	 * @param {IEmmetEditor} editor Editor instance
	 * @param {String} direction Direction of pair matching: 'in' or 'out'. 
	 * Default is 'out'
	 */
	function matchPair(editor, direction) {
		direction = String((direction || 'out').toLowerCase());
		var info = require('editorUtils').outputInfo(editor);
 
		var range = require('range');
		/** @type Range */
		var sel = range.create(editor.getSelectionRange());
		var content = info.content;
 
		// validate previous match
		if (lastMatch && !lastMatch.range.equal(sel)) {
			lastMatch = null;
		}
 
		if (lastMatch && sel.length()) {
			if (direction == 'in') {
				// user has previously selected tag and wants to move inward
				if (lastMatch.type == 'tag' && !lastMatch.close) {
					// unary tag was selected, can't move inward
					return false;
				} else {
					if (lastMatch.range.equal(lastMatch.outerRange)) {
						lastMatch.range = lastMatch.innerRange;
					} else {
						var narrowed = require('utils').narrowToNonSpace(content, lastMatch.innerRange);
						lastMatch = matcher.find(content, narrowed.start + 1);
						if (lastMatch && lastMatch.range.equal(sel) && lastMatch.outerRange.equal(sel)) {
							lastMatch.range = lastMatch.innerRange;
						}
					}
				}
			} else {
				if (
						!lastMatch.innerRange.equal(lastMatch.outerRange) 
						&& lastMatch.range.equal(lastMatch.innerRange) 
						&& sel.equal(lastMatch.range)) {
					lastMatch.range = lastMatch.outerRange;
				} else {
					lastMatch = matcher.find(content, sel.start);
					if (lastMatch && lastMatch.range.equal(sel) && lastMatch.innerRange.equal(sel)) {
						lastMatch.range = lastMatch.outerRange;
					}
				}
			}
		} else {
			lastMatch = matcher.find(content, sel.start);
		}
 
		if (lastMatch && !lastMatch.range.equal(sel)) {
			editor.createSelection(lastMatch.range.start, lastMatch.range.end);
			return true;
		}
 
		lastMatch = null;
		return false;
	}
 
	...
 
}

при беглом изучении этого кода, можно выделить такую фразу:

		// validate previous match
		if (lastMatch && !lastMatch.range.equal(sel)) {
			lastMatch = null;
		}

Вон оно что. Тут у нас статическая переменная lastMatch. Дело в том, что если JavaScript скрипт каждый раз подключается заново, статические переменные теряют смысл, так как они каждый раз заново инициализируются.

В браузерах после загрузки страницы все скрипты остаются в памяти и статические переменные работают как надо. В Notepad++ также подключение скрипта emmet-app.js происходит только один раз при первом вызове (используется Python-wrapper для JavaScript), поэтому там также всё работает более-менее нормально.

А в AkelPad‘е каждый раз мы запускаем скрипт заново, то есть он не висит в памяти, занимая пространство и время редактора, поэтому в нашем случае эта статическая переменная бесполезна.

Штука такая, что на самом деле от статической составляющей lastMatch можно уйти. Предположив, что если у нас что-то выбрано, lastMatch уже отработал и нам надо искать дальше.

То есть мы в любом случае ищем как сначала, переставив местами части.

Вот что в итоге получается:

/**
 * HTML pair matching (balancing) actions
 * @constructor
 * @memberOf __matchPairActionDefine
 * @param {Function} require
 * @param {Underscore} _
 */
emmet.exec(function(require, _) {
	/** @type emmet.actions */
	var actions = require('actions');
	var matcher = require('htmlMatcher');
	var lastMatch = null;
 
	/**
	 * Find and select HTML tag pair
	 * @param {IEmmetEditor} editor Editor instance
	 * @param {String} direction Direction of pair matching: 'in' or 'out'. 
	 * Default is 'out'
	 */
	function matchPair(editor, direction) {
		direction = String((direction || 'out').toLowerCase());
		var info = require('editorUtils').outputInfo(editor);
 
		var range = require('range');
		/** @type Range */
		var sel = range.create(editor.getSelectionRange());
		var content = info.content;
 
		// we do not have static lastMatch here
		lastMatch = matcher.find(content, sel.start);
		// try for in balancing
		if(!lastMatch)
			lastMatch = matcher.find(content, sel.start + 1);
 
		if (lastMatch && sel.length()) {
			if (direction == 'in') {
				// user has previously selected tag and wants to move inward
				if (lastMatch.type == 'tag' && !lastMatch.close) {
					// unary tag was selected, can't move inward
					return false;
				} else {
					if (lastMatch.range.equal(lastMatch.outerRange)) {
						lastMatch.range = lastMatch.innerRange;
					} else {
						var narrowed = require('utils').narrowToNonSpace(content, lastMatch.innerRange);
						lastMatch = matcher.find(content, narrowed.start + 1);
						if (lastMatch && lastMatch.range.equal(sel) && lastMatch.outerRange.equal(sel)) {
							lastMatch.range = lastMatch.innerRange;
						}
					}
				}
			} else {
				if (
						!lastMatch.innerRange.equal(lastMatch.outerRange) 
						&& lastMatch.range.equal(lastMatch.innerRange) 
						&& sel.equal(lastMatch.range)) {
					lastMatch.range = lastMatch.outerRange;
				} else {
					lastMatch = matcher.find(content, sel.start);
					if (lastMatch && lastMatch.range.equal(sel) && lastMatch.innerRange.equal(sel)) {
						lastMatch.range = lastMatch.outerRange;
					}
				}
			}
		}
 
		if (lastMatch && !lastMatch.range.equal(sel)) {
			editor.createSelection(lastMatch.range.start, lastMatch.range.end);
			return true;
		}
 
		return false;
	}
 
	...
 
}

Для balancing inward я добавил такую фичу:

		// try for in balancing
		if(!lastMatch)
			lastMatch = matcher.find(content, sel.start + 1);

то есть пытаемся найти с позиции следующей после каретки, углубляясь глубже в дерево DOM.

Иногда это может привести к добавлению лишнего символа в выборе (если тэги плохо сбалансированы), но итоговый результат стоит того, так как в подавляющем большинстве случаев это работает нормально.

Но сам движок Сергея по поиску HTML/XML тэгов (matcher.find) нуждается в доработке, так он вне HTML может спотыкаться при использовании экранированных кавычек и ряда других случаях. Но этим, насколько я мог заметить, грешат практически все наверное подобные алгоритмы, так как найти matching tag в определённых случаях бывает довольно тяжело.

На сим всё, низкий поклон, пошёл завтракать яичницу с мясом (не беконом, а свининой, оставшейся после вчерашнего ужина).

Благодарю Вас за чтение и Господа за еду.