mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-04-25 10:18:31 +02:00
Namespaced template functions (#95)
This commit is contained in:
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user