Skip to content

Commit

Permalink
Support namespaced template functions in parser and autocomplete
Browse files Browse the repository at this point in the history
  • Loading branch information
gschier committed Sep 18, 2024
1 parent d48b29c commit 3f516ef
Show file tree
Hide file tree
Showing 5 changed files with 108 additions and 26 deletions.
45 changes: 44 additions & 1 deletion src-tauri/yaak_templates/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down
9 changes: 5 additions & 4 deletions src-web/components/core/Editor/Editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -267,10 +272,6 @@
content: 'm' !important;
}

&.cm-completionIcon-namespace::after {
content: 'n' !important;
}

&.cm-completionIcon-property::after {
content: 'a' !important;
}
Expand Down
65 changes: 49 additions & 16 deletions src-web/components/core/Editor/twig/completion.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { CompletionContext } from '@codemirror/autocomplete';
import type { Completion, CompletionContext } from '@codemirror/autocomplete';

const openTag = '${[ ';
const closeTag = ' ]}';
Expand All @@ -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;
Expand All @@ -25,12 +33,13 @@ 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 toStartOfNamespacedName = context.matchBefore(/[\w_.]*/);
const toStartOfVariable = context.matchBefore(/\$\{?\[?\s*[\w_]*/);
const toMatch = toStartOfVariable ?? toStartOfName ?? null;

if (toMatch === null) return null;
Expand All @@ -47,22 +56,46 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
return null;
}

const completions: Completion[] = options
.map((o): Completion => {
const optionSegments = o.name.split('.');
const matchSegments = toStartOfNamespacedName!.text.split('.');
// const prefix = toStartOfNamespacedName!.text
// .split('.')
// .slice(0, matchSegments.length)
// .join('.');

// // Remove anything that doesn't start with the prefixed text
// if (!o.name.startsWith(prefix)) {
// return null;
// }

// 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),
};
Expand Down
14 changes: 9 additions & 5 deletions src-web/components/core/Editor/twig/templateTags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?.();
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src-web/hooks/useRenderTemplate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
});
Expand Down

0 comments on commit 3f516ef

Please sign in to comment.