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>)> { fn parse_fn(&mut self) -> Option<(String, Vec<FnArg>)> {
let start_pos = self.pos; let start_pos = self.pos;
let name = match self.parse_ident() { let name = match self.parse_fn_name() {
Some(v) => v, Some(v) => v,
None => { None => {
self.pos = start_pos; self.pos = start_pos;
@@ -292,6 +292,32 @@ impl Parser {
Some(text) 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> { fn parse_string(&mut self) -> Option<String> {
let start_pos = self.pos; 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] #[test]
fn fn_ident_arg() { fn fn_ident_arg() {

View File

@@ -242,6 +242,11 @@
@apply text-primary; @apply text-primary;
} }
&.cm-completionIcon-namespace::after {
content: 'n' !important;
@apply text-warning;
}
&.cm-completionIcon-constant::after { &.cm-completionIcon-constant::after {
content: 'c' !important; content: 'c' !important;
@apply text-notice; @apply text-notice;
@@ -267,10 +272,6 @@
content: 'm' !important; content: 'm' !important;
} }
&.cm-completionIcon-namespace::after {
content: 'n' !important;
}
&.cm-completionIcon-property::after { &.cm-completionIcon-property::after {
content: 'a' !important; 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 openTag = '${[ ';
const closeTag = ' ]}'; const closeTag = ' ]}';
@@ -7,12 +7,20 @@ export type TwigCompletionOptionVariable = {
type: 'variable'; type: 'variable';
}; };
export type TwigCompletionOptionNamespace = {
type: 'namespace';
};
export type TwigCompletionOptionFunction = { export type TwigCompletionOptionFunction = {
args: { name: string }[]; args: { name: string }[];
type: 'function'; type: 'function';
}; };
export type TwigCompletionOption = (TwigCompletionOptionFunction | TwigCompletionOptionVariable) & { export type TwigCompletionOption = (
| TwigCompletionOptionFunction
| TwigCompletionOptionVariable
| TwigCompletionOptionNamespace
) & {
name: string; name: string;
label: string; label: string;
onClick: (rawTag: string, startPos: number) => void; onClick: (rawTag: string, startPos: number) => void;
@@ -25,12 +33,12 @@ export interface TwigCompletionConfig {
} }
const MIN_MATCH_VAR = 1; const MIN_MATCH_VAR = 1;
const MIN_MATCH_NAME = 2; const MIN_MATCH_NAME = 1;
export function twigCompletion({ options }: TwigCompletionConfig) { export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) { return function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/\w*/); const toStartOfName = context.matchBefore(/[\w_.]*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*\w*/); const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*[\w_]*/);
const toMatch = toStartOfVariable ?? toStartOfName ?? null; const toMatch = toStartOfVariable ?? toStartOfName ?? null;
if (toMatch === null) return null; if (toMatch === null) return null;
@@ -47,22 +55,37 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
return null; 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 // 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. // open it, then it closes when you type the next character.
return { return {
validFor: () => true, // Not really sure why this is all it needs validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from, from: toMatch.from,
options: options matchLen,
.filter((v) => v.name.trim()) options: completions
.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,
};
})
// Filter out exact matches // Filter out exact matches
.filter((o) => o.label !== toMatch.text), .filter((o) => o.label !== toMatch.text),
}; };

View File

@@ -9,7 +9,11 @@ import type { TwigCompletionOption } from './completion';
class PathPlaceholderWidget extends WidgetType { class PathPlaceholderWidget extends WidgetType {
readonly #clickListenerCallback: () => void; readonly #clickListenerCallback: () => void;
constructor(readonly rawText: string, readonly startPos: number, readonly onClick: () => void) { constructor(
readonly rawText: string,
readonly startPos: number,
readonly onClick: () => void,
) {
super(); super();
this.#clickListenerCallback = () => { this.#clickListenerCallback = () => {
this.onClick?.(); this.onClick?.();
@@ -68,10 +72,10 @@ class TemplateTagWidget extends WidgetType {
this.option.invalid this.option.invalid
? 'x-theme-templateTag--danger' ? 'x-theme-templateTag--danger'
: this.option.type === 'variable' : this.option.type === 'variable'
? 'x-theme-templateTag--primary' ? 'x-theme-templateTag--primary'
: 'x-theme-templateTag--info' : '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.setAttribute('data-tag-type', this.option.type);
elt.textContent = elt.textContent =
this.option.type === 'variable' this.option.type === 'variable'
@@ -134,7 +138,7 @@ function templateTags(
// TODO: Search `node.tree` instead of using Regex here // TODO: Search `node.tree` instead of using Regex here
const inner = rawTag.replace(/^\$\{\[\s*/, '').replace(/\s*]}$/, ''); 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. // 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 // 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; const environmentId = useActiveEnvironment()[0]?.id ?? null;
return useQuery<string>({ return useQuery<string>({
placeholderData: (prev) => prev, // Keep previous data on refetch placeholderData: (prev) => prev, // Keep previous data on refetch
refetchOnWindowFocus: false,
queryKey: ['render_template', template], queryKey: ['render_template', template],
queryFn: () => renderTemplate({ template, workspaceId, environmentId }), queryFn: () => renderTemplate({ template, workspaceId, environmentId }),
}); });