Node syntaxTree to parse template tags

This commit is contained in:
Gregory Schier
2024-08-26 11:30:10 -07:00
parent 124fb35dcd
commit 24a4e3494e
3 changed files with 64 additions and 49 deletions

View File

@@ -68,7 +68,7 @@ const syntaxExtensions: Record<string, LanguageSupport> = {
'application/graphql': graphqlLanguageSupport(), 'application/graphql': graphqlLanguageSupport(),
'application/json': json(), 'application/json': json(),
'application/javascript': javascript(), 'application/javascript': javascript(),
'text/html': xml(), // HTML as xml because HTML is oddly slow 'text/html': xml(), // HTML as XML because HTML is oddly slow
'application/xml': xml(), 'application/xml': xml(),
'text/xml': xml(), 'text/xml': xml(),
url: url(), url: url(),

View File

@@ -7,7 +7,7 @@ import { genericCompletion } from '../genericCompletion';
import { textLanguageName } from '../text/extension'; import { textLanguageName } from '../text/extension';
import type { TwigCompletionOption } from './completion'; import type { TwigCompletionOption } from './completion';
import { twigCompletion } from './completion'; import { twigCompletion } from './completion';
import { templateTags } from './templateTags'; import { templateTagsPlugin } from './templateTags';
import { parser as twigParser } from './twig'; import { parser as twigParser } from './twig';
export function twig({ export function twig({
@@ -62,7 +62,7 @@ export function twig({
return [ return [
language, language,
base.support, base.support,
templateTags(options, onClickMissingVariable), templateTagsPlugin(options, onClickMissingVariable),
language.data.of({ autocomplete: completions }), language.data.of({ autocomplete: completions }),
base.language.data.of({ autocomplete: completions }), base.language.data.of({ autocomplete: completions }),
language.data.of({ autocomplete: genericCompletion(autocomplete) }), language.data.of({ autocomplete: genericCompletion(autocomplete) }),

View File

@@ -1,6 +1,8 @@
import { syntaxTree } from '@codemirror/language';
import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view'; import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view'; import { Decoration, ViewPlugin, WidgetType } from '@codemirror/view';
import { BetterMatchDecorator } from '../BetterMatchDecorator'; import { EditorView } from 'codemirror';
import type { TwigCompletionOption } from './completion'; import type { TwigCompletionOption } from './completion';
class TemplateTagWidget extends WidgetType { class TemplateTagWidget extends WidgetType {
@@ -22,7 +24,8 @@ class TemplateTagWidget extends WidgetType {
this.option.name === other.option.name && this.option.name === other.option.name &&
this.option.type === other.option.type && this.option.type === other.option.type &&
this.option.value === other.option.value && this.option.value === other.option.value &&
this.rawTag === other.rawTag this.rawTag === other.rawTag &&
this.startPos === other.startPos
); );
} }
@@ -55,69 +58,81 @@ class TemplateTagWidget extends WidgetType {
} }
} }
export function templateTags( function templateTags(
view: EditorView,
options: TwigCompletionOption[], options: TwigCompletionOption[],
onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void, onClickMissingVariable: (name: string, rawTag: string, startPos: number) => void,
) { ): DecorationSet {
const templateTagMatcher = new BetterMatchDecorator({ const widgets: Range<Decoration>[] = [];
regexp: /\$\{\[\s*(.+)(?!]})\s*]}/g, for (const { from, to } of view.visibleRanges) {
decoration(match, view, matchStartPos) { syntaxTree(view.state).iterate({
const matchEndPos = matchStartPos + match[0].length - 1; from,
to,
enter: (node) => {
if (node.name == 'Tag') {
// Don't decorate if the cursor is inside the match
for (const r of view.state.selection.ranges) {
if (r.from > node.from && r.to < node.to) {
return;
}
}
// Don't decorate if the cursor is inside the match const rawTag = view.state.doc.sliceString(node.from, node.to);
for (const r of view.state.selection.ranges) {
if (r.from > matchStartPos && r.to <= matchEndPos) { // TODO: Search `node.tree` instead of using Regex here
return Decoration.replace({}); const inner = rawTag.replace(/^\$\{\[\s*/, '').replace(/\s*]}$/, '');
const name = inner.match(/(\w+)[(]/)?.[1] ?? inner;
let option = options.find((v) => v.name === name);
if (option == null) {
option = {
invalid: true,
type: 'variable',
name: inner,
value: null,
label: inner,
onClick: () => onClickMissingVariable(name, rawTag, node.from),
};
}
const widget = new TemplateTagWidget(option, rawTag, node.from);
const deco = Decoration.replace({ widget, inclusive: true });
widgets.push(deco.range(node.from, node.to));
} }
} },
});
const innerTagMatch = match[1]; }
if (innerTagMatch == null) { return Decoration.set(widgets);
// Should never happen, but make TS happy }
console.warn('Group match was empty', match);
return Decoration.replace({});
}
// TODO: Replace this hacky match with a proper template parser
const name = innerTagMatch.match(/\s*(\w+)[(\s]*/)?.[1] ?? innerTagMatch;
let option = options.find((v) => v.name === name);
if (option == null) {
option = {
invalid: true,
type: 'variable',
name: innerTagMatch,
value: null,
label: innerTagMatch,
onClick: () => onClickMissingVariable(name, match[0], matchStartPos),
};
}
return Decoration.replace({
inclusive: true,
widget: new TemplateTagWidget(option, match[0], matchStartPos),
});
},
});
export function templateTagsPlugin(
options: TwigCompletionOption[],
onClickMissingVariable: (name: string, tagValue: string, startPos: number) => void,
) {
return ViewPlugin.fromClass( return ViewPlugin.fromClass(
class { class {
decorations: DecorationSet; decorations: DecorationSet;
constructor(view: EditorView) { constructor(view: EditorView) {
this.decorations = templateTagMatcher.createDeco(view); this.decorations = templateTags(view, options, onClickMissingVariable);
} }
update(update: ViewUpdate) { update(update: ViewUpdate) {
this.decorations = templateTagMatcher.updateDeco(update, this.decorations); this.decorations = templateTags(update.view, options, onClickMissingVariable);
} }
}, },
{ {
decorations: (instance) => instance.decorations, decorations: (v) => v.decorations,
provide: (plugin) => provide: (plugin) =>
EditorView.atomicRanges.of((view) => { EditorView.atomicRanges.of((view) => {
return view.plugin(plugin)?.decorations || Decoration.none; return view.plugin(plugin)?.decorations || Decoration.none;
}), }),
eventHandlers: {
mousedown: (e) => {
const target = e.target as HTMLElement;
if (target.classList.contains('template-tag')) console.log('CLICKED TEMPLATE TAG');
// return toggleBoolean(view, view.posAtDOM(target));
},
},
}, },
); );
} }