Namespaced template functions (#95)

This commit is contained in:
Gregory Schier
2024-09-18 05:36:37 -07:00
committed by GitHub
parent d48b29c6e9
commit 844d795014
5 changed files with 98 additions and 26 deletions

View File

@@ -195,7 +195,7 @@ impl Parser {
fn parse_fn(&mut self) -> Option<(String, Vec<FnArg>)> {
let start_pos = self.pos;
let name = match self.parse_ident() {
let name = match self.parse_fn_name() {
Some(v) => v,
None => {
self.pos = start_pos;
@@ -292,6 +292,32 @@ impl Parser {
Some(text)
}
fn parse_fn_name(&mut self) -> Option<String> {
let start_pos = self.pos;
let mut text = String::new();
while self.pos < self.chars.len() {
let ch = self.peek_char();
if ch.is_alphanumeric() || ch == '_' || ch == '.' {
text.push(ch);
self.pos += 1;
} else {
break;
}
if start_pos == self.pos {
panic!("Parser stuck!");
}
}
if text.is_empty() {
self.pos = start_pos;
return None;
}
Some(text)
}
fn parse_string(&mut self) -> Option<String> {
let start_pos = self.pos;
@@ -486,6 +512,23 @@ mod tests {
]
);
}
#[test]
fn fn_dot_name() {
let mut p = Parser::new("${[ foo.bar.baz() ]}");
assert_eq!(
p.parse().tokens,
vec![
Token::Tag {
val: Val::Fn {
name: "foo.bar.baz".into(),
args: Vec::new(),
}
},
Token::Eof
]
);
}
#[test]
fn fn_ident_arg() {

View File

@@ -242,6 +242,11 @@
@apply text-primary;
}
&.cm-completionIcon-namespace::after {
content: 'n' !important;
@apply text-warning;
}
&.cm-completionIcon-constant::after {
content: 'c' !important;
@apply text-notice;
@@ -267,10 +272,6 @@
content: 'm' !important;
}
&.cm-completionIcon-namespace::after {
content: 'n' !important;
}
&.cm-completionIcon-property::after {
content: 'a' !important;
}

View File

@@ -1,4 +1,4 @@
import type { CompletionContext } from '@codemirror/autocomplete';
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
const openTag = '${[ ';
const closeTag = ' ]}';
@@ -7,12 +7,20 @@ export type TwigCompletionOptionVariable = {
type: 'variable';
};
export type TwigCompletionOptionNamespace = {
type: 'namespace';
};
export type TwigCompletionOptionFunction = {
args: { name: string }[];
type: 'function';
};
export type TwigCompletionOption = (TwigCompletionOptionFunction | TwigCompletionOptionVariable) & {
export type TwigCompletionOption = (
| TwigCompletionOptionFunction
| TwigCompletionOptionVariable
| TwigCompletionOptionNamespace
) & {
name: string;
label: string;
onClick: (rawTag: string, startPos: number) => void;
@@ -25,12 +33,12 @@ export interface TwigCompletionConfig {
}
const MIN_MATCH_VAR = 1;
const MIN_MATCH_NAME = 2;
const MIN_MATCH_NAME = 1;
export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/\w*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/);
const toStartOfName = context.matchBefore(/[\w_.]*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*[\w_]*/);
const toMatch = toStartOfVariable ?? toStartOfName ?? null;
if (toMatch === null) return null;
@@ -47,22 +55,37 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
return null;
}
const completions: Completion[] = options
.map((o): Completion => {
const matchSegments = toStartOfName!.text.split('.');
const optionSegments = o.name.split('.');
// If not on the last segment, only complete the namespace
if (matchSegments.length < optionSegments.length) {
return {
label: optionSegments.slice(0, matchSegments.length).join('.'),
apply: optionSegments.slice(0, matchSegments.length).join('.'),
type: 'namespace',
};
}
// If on the last segment, wrap the entire tag
const inner = o.type === 'function' ? `${o.name}()` : o.name;
return {
label: o.name,
apply: openTag + inner + closeTag,
type: o.type === 'variable' ? 'variable' : 'function',
};
})
.filter((v) => v != null);
// TODO: Figure out how to make autocomplete stay open if opened explicitly. It sucks when you explicitly
// open it, then it closes when you type the next character.
return {
validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from,
options: options
.filter((v) => v.name.trim())
.map((v) => {
const inner = v.type === 'function' ? `${v.name}()` : v.name;
return {
label: v.label,
apply: openTag + inner + closeTag,
type: v.type === 'variable' ? 'variable' : 'function',
matchLen: matchLen,
};
})
matchLen,
options: completions
// Filter out exact matches
.filter((o) => o.label !== toMatch.text),
};

View File

@@ -9,7 +9,11 @@ import type { TwigCompletionOption } from './completion';
class PathPlaceholderWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
constructor(readonly rawText: string, readonly startPos: number, readonly onClick: () => void) {
constructor(
readonly rawText: string,
readonly startPos: number,
readonly onClick: () => void,
) {
super();
this.#clickListenerCallback = () => {
this.onClick?.();
@@ -68,10 +72,10 @@ class TemplateTagWidget extends WidgetType {
this.option.invalid
? 'x-theme-templateTag--danger'
: this.option.type === 'variable'
? 'x-theme-templateTag--primary'
: 'x-theme-templateTag--info'
? 'x-theme-templateTag--primary'
: 'x-theme-templateTag--info'
}`;
elt.title = this.option.invalid ? 'Not Found' : this.option.value ?? '';
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
elt.setAttribute('data-tag-type', this.option.type);
elt.textContent =
this.option.type === 'variable'
@@ -134,7 +138,7 @@ function templateTags(
// TODO: Search `node.tree` instead of using Regex here
const inner = rawTag.replace(/^\$\{\[\s*/, '').replace(/\s*]}$/, '');
let name = inner.match(/(\w+)[(]/)?.[1] ?? inner;
let name = inner.match(/([\w.]+)[(]/)?.[1] ?? inner;
// The beta named the function `Response` but was changed in stable.
// Keep this here for a while because there's no easy way to migrate

View File

@@ -8,6 +8,7 @@ export function useRenderTemplate(template: string) {
const environmentId = useActiveEnvironment()[0]?.id ?? null;
return useQuery<string>({
placeholderData: (prev) => prev, // Keep previous data on refetch
refetchOnWindowFocus: false,
queryKey: ['render_template', template],
queryFn: () => renderTemplate({ template, workspaceId, environmentId }),
});