Skip to content

Commit aa28859

Browse files
M function matcher vue template (#4317)
Closes opral/sherlock#197 Add a failing unit test to reproduce the issue where the m-function matcher doesn't match calls in Vue templates. --- <a href="https://cursor.com/background-agent?bcId=bc-7702ee9c-de8a-42af-9acf-d57050db30d7"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-cursor-light.svg"><img alt="Open in Cursor" src="https://cursor.com/open-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?id=bc-7702ee9c-de8a-42af-9acf-d57050db30d7"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/open-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/open-in-web-light.svg"><img alt="Open in Web" src="https://cursor.com/open-in-web.svg"></picture></a> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Enhances message matching to better detect usages and reduce false positives. > > - Match `m()` references inside Vue `<template>` blocks and when used before the import appears > - Require a real `m` import (`import * as m` or named `{ m }`) before returning any matches > - Strip comments/strings during the import scan to avoid false positives (e.g. commented or stringified imports) > - Adjust parser to validate the character before `m`, and refactor `createParser` to accept `sourceCode` > - Update/add tests covering Vue templates, pre-import usage, and ignoring commented/string imports > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1c0fcdf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent d0b71b9 commit aa28859

File tree

3 files changed

+189
-6
lines changed

3 files changed

+189
-6
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@inlang/plugin-m-function-matcher": minor
3+
---
4+
5+
Match m() references in Vue templates and before imports. Fixes
6+
https://github.com/opral/sherlock/issues/197.

packages/plugins/m-function-matcher/src/ideExtension/messageReferenceMatchers.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,23 @@ describe("Paraglide Message Parser", () => {
432432
]);
433433
});
434434

435+
it("repro: should match m calls in Vue template before import", () => {
436+
const sourceCode = `
437+
<template>
438+
<span>{{ m["common.addNew"]() }}</span>
439+
</template>
440+
<script setup lang="ts">
441+
import * as m from "@/paraglide/messages";
442+
const testRef = m["common.addNew"]();
443+
</script>
444+
`;
445+
const result = parse(sourceCode) as Array<{ messageId: string }>;
446+
expect(result.map((item) => item.messageId)).toEqual([
447+
"common.addNew",
448+
"common.addNew",
449+
]);
450+
});
451+
435452
it("should match if both dot and bracket notation are used in the same file", () => {
436453
const sourceCode = `
437454
import * as m from "../../i18n-generated/messages";
@@ -471,7 +488,15 @@ describe("Paraglide Message Parser", () => {
471488
import * as m from "../../i18n-generated/messages";
472489
`;
473490
const result = parse(sourceCode);
474-
expect(result).toEqual([]);
491+
expect(result).toEqual([
492+
{
493+
messageId: "helloWorld",
494+
position: {
495+
start: { line: 2, character: 5 },
496+
end: { line: 2, character: 17 },
497+
},
498+
},
499+
]);
475500
});
476501

477502
it("should match if m is defined but no reference to paraglide", () => {
@@ -482,6 +507,17 @@ describe("Paraglide Message Parser", () => {
482507
expect(result).toEqual([]);
483508
});
484509

510+
it("should ignore m imports in comments or strings", () => {
511+
const sourceCode = `
512+
const m = Math;
513+
m.max(1, 2);
514+
// import { m } from "@/paraglide/messages"
515+
const note = "import { m } from '@/paraglide/messages'";
516+
`;
517+
const result = parse(sourceCode);
518+
expect(result).toEqual([]);
519+
});
520+
485521
it("should match if m is defined but has a spell error", () => {
486522
const sourceCode = `
487523
import * as m from "../../i18n-generated/messages";

packages/plugins/m-function-matcher/src/ideExtension/messageReferenceMatchers.ts

Lines changed: 146 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,137 @@
99

1010
import Parsimmon from "parsimmon";
1111

12-
const createParser = () => {
12+
const mImportPattern = /import\s+(?:\*\s+as\s+m|\{\s*[^}]*\bm\b[^}]*\})/;
13+
14+
const stripCommentsAndStrings = (sourceCode: string) => {
15+
let result = "";
16+
let i = 0;
17+
const length = sourceCode.length;
18+
let state: "code" | "singleLineComment" | "multiLineComment" | "singleQuote" | "doubleQuote" | "template" =
19+
"code";
20+
21+
while (i < length) {
22+
const char = sourceCode[i];
23+
const next = sourceCode[i + 1];
24+
25+
if (state === "code") {
26+
if (char === "/" && next === "/") {
27+
state = "singleLineComment";
28+
result += " ";
29+
i += 2;
30+
continue;
31+
}
32+
if (char === "/" && next === "*") {
33+
state = "multiLineComment";
34+
result += " ";
35+
i += 2;
36+
continue;
37+
}
38+
if (char === "'") {
39+
state = "singleQuote";
40+
result += " ";
41+
i += 1;
42+
continue;
43+
}
44+
if (char === '"') {
45+
state = "doubleQuote";
46+
result += " ";
47+
i += 1;
48+
continue;
49+
}
50+
if (char === "`") {
51+
state = "template";
52+
result += " ";
53+
i += 1;
54+
continue;
55+
}
56+
result += char;
57+
i += 1;
58+
continue;
59+
}
60+
61+
if (state === "singleLineComment") {
62+
if (char === "\n") {
63+
state = "code";
64+
result += "\n";
65+
} else {
66+
result += " ";
67+
}
68+
i += 1;
69+
continue;
70+
}
71+
72+
if (state === "multiLineComment") {
73+
if (char === "*" && next === "/") {
74+
state = "code";
75+
result += " ";
76+
i += 2;
77+
continue;
78+
}
79+
result += char === "\n" ? "\n" : " ";
80+
i += 1;
81+
continue;
82+
}
83+
84+
if (state === "singleQuote") {
85+
if (char === "\\" && i + 1 < length) {
86+
result += " ";
87+
i += 2;
88+
continue;
89+
}
90+
if (char === "'") {
91+
state = "code";
92+
result += " ";
93+
i += 1;
94+
continue;
95+
}
96+
result += char === "\n" ? "\n" : " ";
97+
i += 1;
98+
continue;
99+
}
100+
101+
if (state === "doubleQuote") {
102+
if (char === "\\" && i + 1 < length) {
103+
result += " ";
104+
i += 2;
105+
continue;
106+
}
107+
if (char === '"') {
108+
state = "code";
109+
result += " ";
110+
i += 1;
111+
continue;
112+
}
113+
result += char === "\n" ? "\n" : " ";
114+
i += 1;
115+
continue;
116+
}
117+
118+
if (state === "template") {
119+
if (char === "\\" && i + 1 < length) {
120+
result += " ";
121+
i += 2;
122+
continue;
123+
}
124+
if (char === "`") {
125+
state = "code";
126+
result += " ";
127+
i += 1;
128+
continue;
129+
}
130+
result += char === "\n" ? "\n" : " ";
131+
i += 1;
132+
continue;
133+
}
134+
}
135+
136+
return result;
137+
};
138+
139+
const createParser = (sourceCode: string) => {
13140
return Parsimmon.createLanguage({
14141
entry: (r) => {
15-
return Parsimmon.alt(r.findReference!, Parsimmon.any)
142+
return Parsimmon.alt(r.findMessage!, Parsimmon.any)
16143
.many()
17144
.map((matches) => matches.flatMap((match) => match))
18145
.map((matches) =>
@@ -94,12 +221,22 @@ const createParser = () => {
94221

95222
findMessage: (r) => {
96223
return Parsimmon.seqMap(
97-
Parsimmon.regex(/.*?(?<![a-zA-Z0-9/])m/s), // find m that's not preceded by letters/numbers
224+
Parsimmon.index, // capture start offset
225+
Parsimmon.regex(/.*?m/s), // find earliest m from current position
98226
Parsimmon.alt(r.dotNotation!, r.bracketNotation!).or(
99227
Parsimmon.succeed(null)
100228
),
101229
Parsimmon.regex(/\((?:[^()]|\([^()]*\))*\)/).or(Parsimmon.succeed("")), // function arguments or empty string
102-
(_, notation, args) => {
230+
(startIndex, match, notation, args) => {
231+
const mOffset = startIndex.offset + match.length - 1;
232+
const prevChar =
233+
mOffset > 0 ? sourceCode[mOffset - 1] ?? "" : "";
234+
const hasValidPrefix =
235+
mOffset === 0 || !/[a-zA-Z0-9/]/.test(prevChar);
236+
237+
if (!hasValidPrefix) {
238+
return null;
239+
}
103240
// false positive (m not followed by dot or bracket notation)
104241
if (notation === null) {
105242
return null;
@@ -126,7 +263,11 @@ const createParser = () => {
126263
// Parse the expression
127264
export function parse(sourceCode: string) {
128265
try {
129-
const parser = createParser();
266+
const scanSource = sourceCode ? stripCommentsAndStrings(sourceCode) : "";
267+
if (!scanSource || !mImportPattern.test(scanSource)) {
268+
return [];
269+
}
270+
const parser = createParser(sourceCode);
130271
return parser.entry!.tryParse(sourceCode);
131272
} catch (e) {
132273
return [];

0 commit comments

Comments
 (0)