470 lines
14 KiB
JavaScript
470 lines
14 KiB
JavaScript
let tree;
|
|
|
|
(async () => {
|
|
const CAPTURE_REGEX = /@\s*([\w\._-]+)/g;
|
|
const COLORS_BY_INDEX = [
|
|
'blue',
|
|
'chocolate',
|
|
'darkblue',
|
|
'darkcyan',
|
|
'darkgreen',
|
|
'darkred',
|
|
'darkslategray',
|
|
'dimgray',
|
|
'green',
|
|
'indigo',
|
|
'navy',
|
|
'red',
|
|
'sienna',
|
|
];
|
|
|
|
const scriptURL = document.currentScript.getAttribute('src');
|
|
|
|
const codeInput = document.getElementById('code-input');
|
|
const languageSelect = document.getElementById('language-select');
|
|
const loggingCheckbox = document.getElementById('logging-checkbox');
|
|
const outputContainer = document.getElementById('output-container');
|
|
const outputContainerScroll = document.getElementById('output-container-scroll');
|
|
const playgroundContainer = document.getElementById('playground-container');
|
|
const queryCheckbox = document.getElementById('query-checkbox');
|
|
const queryContainer = document.getElementById('query-container');
|
|
const queryInput = document.getElementById('query-input');
|
|
const updateTimeSpan = document.getElementById('update-time');
|
|
const languagesByName = {};
|
|
|
|
loadState();
|
|
|
|
await TreeSitter.init();
|
|
|
|
const parser = new TreeSitter();
|
|
const codeEditor = CodeMirror.fromTextArea(codeInput, {
|
|
lineNumbers: true,
|
|
showCursorWhenSelecting: true
|
|
});
|
|
|
|
const queryEditor = CodeMirror.fromTextArea(queryInput, {
|
|
lineNumbers: true,
|
|
showCursorWhenSelecting: true
|
|
});
|
|
|
|
const cluster = new Clusterize({
|
|
rows: [],
|
|
noDataText: null,
|
|
contentElem: outputContainer,
|
|
scrollElem: outputContainerScroll
|
|
});
|
|
const renderTreeOnCodeChange = debounce(renderTree, 50);
|
|
const saveStateOnChange = debounce(saveState, 2000);
|
|
const runTreeQueryOnChange = debounce(runTreeQuery, 50);
|
|
|
|
let languageName = languageSelect.value;
|
|
let treeRows = null;
|
|
let treeRowHighlightedIndex = -1;
|
|
let parseCount = 0;
|
|
let isRendering = 0;
|
|
let query;
|
|
|
|
codeEditor.on('changes', handleCodeChange);
|
|
codeEditor.on('viewportChange', runTreeQueryOnChange);
|
|
codeEditor.on('cursorActivity', debounce(handleCursorMovement, 150));
|
|
queryEditor.on('changes', debounce(handleQueryChange, 150));
|
|
|
|
loggingCheckbox.addEventListener('change', handleLoggingChange);
|
|
queryCheckbox.addEventListener('change', handleQueryEnableChange);
|
|
languageSelect.addEventListener('change', handleLanguageChange);
|
|
outputContainer.addEventListener('click', handleTreeClick);
|
|
|
|
handleQueryEnableChange();
|
|
await handleLanguageChange()
|
|
|
|
playgroundContainer.style.visibility = 'visible';
|
|
|
|
async function handleLanguageChange() {
|
|
const newLanguageName = languageSelect.value;
|
|
if (!languagesByName[newLanguageName]) {
|
|
const url = `${LANGUAGE_BASE_URL}/tree-sitter-${newLanguageName}.wasm`
|
|
languageSelect.disabled = true;
|
|
try {
|
|
languagesByName[newLanguageName] = await TreeSitter.Language.load(url);
|
|
} catch (e) {
|
|
console.error(e);
|
|
languageSelect.value = languageName;
|
|
return
|
|
} finally {
|
|
languageSelect.disabled = false;
|
|
}
|
|
}
|
|
|
|
tree = null;
|
|
languageName = newLanguageName;
|
|
parser.setLanguage(languagesByName[newLanguageName]);
|
|
handleCodeChange();
|
|
handleQueryChange();
|
|
}
|
|
|
|
async function handleCodeChange(editor, changes) {
|
|
const newText = codeEditor.getValue();
|
|
const edits = tree && changes && changes.map(treeEditForEditorChange);
|
|
|
|
const start = performance.now();
|
|
if (edits) {
|
|
for (const edit of edits) {
|
|
tree.edit(edit);
|
|
}
|
|
}
|
|
const newTree = parser.parse(newText, tree);
|
|
const duration = (performance.now() - start).toFixed(1);
|
|
|
|
updateTimeSpan.innerText = `${duration} ms`;
|
|
if (tree) tree.delete();
|
|
tree = newTree;
|
|
parseCount++;
|
|
renderTreeOnCodeChange();
|
|
runTreeQueryOnChange();
|
|
saveStateOnChange();
|
|
}
|
|
|
|
async function renderTree() {
|
|
isRendering++;
|
|
const cursor = tree.walk();
|
|
|
|
let currentRenderCount = parseCount;
|
|
let row = '';
|
|
let rows = [];
|
|
let finishedRow = false;
|
|
let visitedChildren = false;
|
|
let indentLevel = 0;
|
|
|
|
for (let i = 0;; i++) {
|
|
if (i > 0 && i % 10000 === 0) {
|
|
await new Promise(r => setTimeout(r, 0));
|
|
if (parseCount !== currentRenderCount) {
|
|
cursor.delete();
|
|
isRendering--;
|
|
return;
|
|
}
|
|
}
|
|
|
|
let displayName;
|
|
if (cursor.nodeIsMissing) {
|
|
displayName = `MISSING ${cursor.nodeType}`
|
|
} else if (cursor.nodeIsNamed) {
|
|
displayName = cursor.nodeType;
|
|
}
|
|
|
|
if (visitedChildren) {
|
|
if (displayName) {
|
|
finishedRow = true;
|
|
}
|
|
|
|
if (cursor.gotoNextSibling()) {
|
|
visitedChildren = false;
|
|
} else if (cursor.gotoParent()) {
|
|
visitedChildren = true;
|
|
indentLevel--;
|
|
} else {
|
|
break;
|
|
}
|
|
} else {
|
|
if (displayName) {
|
|
if (finishedRow) {
|
|
row += '</div>';
|
|
rows.push(row);
|
|
finishedRow = false;
|
|
}
|
|
const start = cursor.startPosition;
|
|
const end = cursor.endPosition;
|
|
const id = cursor.nodeId;
|
|
let fieldName = cursor.currentFieldName();
|
|
if (fieldName) {
|
|
fieldName += ': ';
|
|
} else {
|
|
fieldName = '';
|
|
}
|
|
row = `<div>${' '.repeat(indentLevel)}${fieldName}<a class='plain' href="#" data-id=${id} data-range="${start.row},${start.column},${end.row},${end.column}">${displayName}</a> [${start.row}, ${start.column}] - [${end.row}, ${end.column}])`;
|
|
finishedRow = true;
|
|
}
|
|
|
|
if (cursor.gotoFirstChild()) {
|
|
visitedChildren = false;
|
|
indentLevel++;
|
|
} else {
|
|
visitedChildren = true;
|
|
}
|
|
}
|
|
}
|
|
if (finishedRow) {
|
|
row += '</div>';
|
|
rows.push(row);
|
|
}
|
|
|
|
cursor.delete();
|
|
cluster.update(rows);
|
|
treeRows = rows;
|
|
isRendering--;
|
|
handleCursorMovement();
|
|
}
|
|
|
|
function runTreeQuery(_, startRow, endRow) {
|
|
if (endRow == null) {
|
|
const viewport = codeEditor.getViewport();
|
|
startRow = viewport.from;
|
|
endRow = viewport.to;
|
|
}
|
|
|
|
codeEditor.operation(() => {
|
|
const marks = codeEditor.getAllMarks();
|
|
marks.forEach(m => m.clear());
|
|
|
|
if (tree && query) {
|
|
const captures = query.captures(
|
|
tree.rootNode,
|
|
{row: startRow, column: 0},
|
|
{row: endRow, column: 0},
|
|
);
|
|
let lastNodeId;
|
|
for (const {name, node} of captures) {
|
|
if (node.id === lastNodeId) continue;
|
|
lastNodeId = node.id;
|
|
const {startPosition, endPosition} = node;
|
|
codeEditor.markText(
|
|
{line: startPosition.row, ch: startPosition.column},
|
|
{line: endPosition.row, ch: endPosition.column},
|
|
{
|
|
inclusiveLeft: true,
|
|
inclusiveRight: true,
|
|
css: `color: ${colorForCaptureName(name)}`
|
|
}
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleQueryChange() {
|
|
if (query) {
|
|
query.delete();
|
|
query.deleted = true;
|
|
query = null;
|
|
}
|
|
|
|
queryEditor.operation(() => {
|
|
queryEditor.getAllMarks().forEach(m => m.clear());
|
|
if (!queryCheckbox.checked) return;
|
|
|
|
const queryText = queryEditor.getValue();
|
|
|
|
try {
|
|
query = parser.getLanguage().query(queryText);
|
|
let match;
|
|
|
|
let row = 0;
|
|
queryEditor.eachLine((line) => {
|
|
while (match = CAPTURE_REGEX.exec(line.text)) {
|
|
queryEditor.markText(
|
|
{line: row, ch: match.index},
|
|
{line: row, ch: match.index + match[0].length},
|
|
{
|
|
inclusiveLeft: true,
|
|
inclusiveRight: true,
|
|
css: `color: ${colorForCaptureName(match[1])}`
|
|
}
|
|
);
|
|
}
|
|
row++;
|
|
});
|
|
} catch (error) {
|
|
const startPosition = queryEditor.posFromIndex(error.index);
|
|
const endPosition = {
|
|
line: startPosition.line,
|
|
ch: startPosition.ch + (error.length || Infinity)
|
|
};
|
|
|
|
if (error.index === queryText.length) {
|
|
if (startPosition.ch > 0) {
|
|
startPosition.ch--;
|
|
} else if (startPosition.row > 0) {
|
|
startPosition.row--;
|
|
startPosition.column = Infinity;
|
|
}
|
|
}
|
|
|
|
queryEditor.markText(
|
|
startPosition,
|
|
endPosition,
|
|
{
|
|
className: 'query-error',
|
|
inclusiveLeft: true,
|
|
inclusiveRight: true,
|
|
attributes: {title: error.message}
|
|
}
|
|
);
|
|
}
|
|
});
|
|
|
|
runTreeQuery();
|
|
saveQueryState();
|
|
}
|
|
|
|
function handleCursorMovement() {
|
|
if (isRendering) return;
|
|
|
|
const selection = codeEditor.getDoc().listSelections()[0];
|
|
let start = {row: selection.anchor.line, column: selection.anchor.ch};
|
|
let end = {row: selection.head.line, column: selection.head.ch};
|
|
if (
|
|
start.row > end.row ||
|
|
(
|
|
start.row === end.row &&
|
|
start.column > end.column
|
|
)
|
|
) {
|
|
let swap = end;
|
|
end = start;
|
|
start = swap;
|
|
}
|
|
const node = tree.rootNode.namedDescendantForPosition(start, end);
|
|
if (treeRows) {
|
|
if (treeRowHighlightedIndex !== -1) {
|
|
const row = treeRows[treeRowHighlightedIndex];
|
|
if (row) treeRows[treeRowHighlightedIndex] = row.replace('highlighted', 'plain');
|
|
}
|
|
treeRowHighlightedIndex = treeRows.findIndex(row => row.includes(`data-id=${node.id}`));
|
|
if (treeRowHighlightedIndex !== -1) {
|
|
const row = treeRows[treeRowHighlightedIndex];
|
|
if (row) treeRows[treeRowHighlightedIndex] = row.replace('plain', 'highlighted');
|
|
}
|
|
cluster.update(treeRows);
|
|
const lineHeight = cluster.options.item_height;
|
|
const scrollTop = outputContainerScroll.scrollTop;
|
|
const containerHeight = outputContainerScroll.clientHeight;
|
|
const offset = treeRowHighlightedIndex * lineHeight;
|
|
if (scrollTop > offset - 20) {
|
|
$(outputContainerScroll).animate({scrollTop: offset - 20}, 150);
|
|
} else if (scrollTop < offset + lineHeight + 40 - containerHeight) {
|
|
$(outputContainerScroll).animate({scrollTop: offset - containerHeight + 40}, 150);
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleTreeClick(event) {
|
|
if (event.target.tagName === 'A') {
|
|
event.preventDefault();
|
|
const [startRow, startColumn, endRow, endColumn] = event
|
|
.target
|
|
.dataset
|
|
.range
|
|
.split(',')
|
|
.map(n => parseInt(n));
|
|
codeEditor.focus();
|
|
codeEditor.setSelection(
|
|
{line: startRow, ch: startColumn},
|
|
{line: endRow, ch: endColumn}
|
|
);
|
|
}
|
|
}
|
|
|
|
function handleLoggingChange() {
|
|
if (loggingCheckbox.checked) {
|
|
parser.setLogger((message, lexing) => {
|
|
if (lexing) {
|
|
console.log(" ", message)
|
|
} else {
|
|
console.log(message)
|
|
}
|
|
});
|
|
} else {
|
|
parser.setLogger(null);
|
|
}
|
|
}
|
|
|
|
function handleQueryEnableChange() {
|
|
if (queryCheckbox.checked) {
|
|
queryContainer.style.visibility = '';
|
|
queryContainer.style.position = '';
|
|
} else {
|
|
queryContainer.style.visibility = 'hidden';
|
|
queryContainer.style.position = 'absolute';
|
|
}
|
|
handleQueryChange();
|
|
}
|
|
|
|
function treeEditForEditorChange(change) {
|
|
const oldLineCount = change.removed.length;
|
|
const newLineCount = change.text.length;
|
|
const lastLineLength = change.text[newLineCount - 1].length;
|
|
|
|
const startPosition = {row: change.from.line, column: change.from.ch};
|
|
const oldEndPosition = {row: change.to.line, column: change.to.ch};
|
|
const newEndPosition = {
|
|
row: startPosition.row + newLineCount - 1,
|
|
column: newLineCount === 1
|
|
? startPosition.column + lastLineLength
|
|
: lastLineLength
|
|
};
|
|
|
|
const startIndex = codeEditor.indexFromPos(change.from);
|
|
let newEndIndex = startIndex + newLineCount - 1;
|
|
let oldEndIndex = startIndex + oldLineCount - 1;
|
|
for (let i = 0; i < newLineCount; i++) newEndIndex += change.text[i].length;
|
|
for (let i = 0; i < oldLineCount; i++) oldEndIndex += change.removed[i].length;
|
|
|
|
return {
|
|
startIndex, oldEndIndex, newEndIndex,
|
|
startPosition, oldEndPosition, newEndPosition
|
|
};
|
|
}
|
|
|
|
function colorForCaptureName(capture) {
|
|
const id = query.captureNames.indexOf(capture);
|
|
return COLORS_BY_INDEX[id % COLORS_BY_INDEX.length];
|
|
}
|
|
|
|
function getLocalStorageItem(key) {
|
|
return localStorage.getItem(`${document.title}:${key}`);
|
|
}
|
|
|
|
function setLocalStorageItem(key, value) {
|
|
localStorage.setItem(`${document.title}:${key}`, value);
|
|
}
|
|
|
|
function loadState() {
|
|
const language = getLocalStorageItem("language");
|
|
const sourceCode = getLocalStorageItem("sourceCode");
|
|
const query = getLocalStorageItem("query");
|
|
const queryEnabled = getLocalStorageItem("queryEnabled");
|
|
if (language != null && sourceCode != null && query != null) {
|
|
queryInput.value = query;
|
|
codeInput.value = sourceCode;
|
|
languageSelect.value = language;
|
|
queryCheckbox.checked = (queryEnabled === 'true');
|
|
}
|
|
}
|
|
|
|
function saveState() {
|
|
setLocalStorageItem("language", languageSelect.value);
|
|
setLocalStorageItem("sourceCode", codeEditor.getValue());
|
|
saveQueryState();
|
|
}
|
|
|
|
function saveQueryState() {
|
|
setLocalStorageItem("queryEnabled", queryCheckbox.checked);
|
|
setLocalStorageItem("query", queryEditor.getValue());
|
|
}
|
|
|
|
function debounce(func, wait, immediate) {
|
|
var timeout;
|
|
return function() {
|
|
var context = this, args = arguments;
|
|
var later = function() {
|
|
timeout = null;
|
|
if (!immediate) func.apply(context, args);
|
|
};
|
|
var callNow = immediate && !timeout;
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
if (callNow) func.apply(context, args);
|
|
};
|
|
}
|
|
})();
|