mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-25 02:41:07 +01: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>)> {
|
||||
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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user