Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ public List<File> filterOutIgnoredFiles(List<File> sources, List<String> ignores
}

public static boolean isPathMatch(String path, String pattern) {
var normalizedPath = Path.of(Utils.noSepAtStart(Utils.normalizePath(path)));
return new FileMatcher(pattern, null).matches(normalizedPath);
var normalizedPath = Path.of(Utils.sepAtStart(Utils.normalizePath(path)));
return new FileMatcher(Utils.sepAtStart(pattern), null).matches(normalizedPath);
}

private String translateToRegex(String node) {
Expand Down
69 changes: 69 additions & 0 deletions src/main/java/com/crowdin/cli/properties/helper/FileMatcher.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ class FileMatcher implements PathMatcher {
pattern = pattern.replaceAll("\\{\\{+", "\\\\{\\\\{");
pattern = pattern.replaceAll("}}+", "\\\\}\\\\}");

// Escape brackets that are not part of valid character class patterns like [0-9], [a-z], etc.
// Valid character classes have the pattern [^...]? where content is a-z, 0-9, or ranges like a-z
pattern = escapeInvalidBrackets(pattern);

// We *could* implement exactly what's documented. The idea would be to implement something like
// Java's Globs.toRegexPattern but supporting only the documented syntax. Instead, we will use
// the real globber.
Expand All @@ -43,4 +47,69 @@ public boolean matches(Path path) {
boolean matches(File file) {
return matches(file.toPath());
}

/**
* Escapes square brackets that are not part of valid character class patterns.
* Valid patterns include: [0-9], [a-z], [abc], etc.
* Invalid patterns like [test.Folder dev] will be escaped to \[test.Folder dev\]
*/
private static String escapeInvalidBrackets(String pattern) {
StringBuilder result = new StringBuilder();
int i = 0;
while (i < pattern.length()) {
if (pattern.charAt(i) == '[') {
int closeIndex = pattern.indexOf(']', i + 1);
if (closeIndex == -1) {
// Unclosed bracket, escape it
result.append("\\[");
i++;
} else {
String bracketContent = pattern.substring(i + 1, closeIndex);
if (isValidCharacterClass(bracketContent)) {
// Valid character class, keep as is
result.append('[').append(bracketContent).append(']');
i = closeIndex + 1;
} else {
// Invalid character class, escape the brackets
result.append("\\[").append(bracketContent).append("\\]");
i = closeIndex + 1;
}
}
} else {
result.append(pattern.charAt(i));
i++;
}
}
return result.toString();
}

/**
* Checks if the content within brackets forms a valid character class.
* Valid patterns: single characters, ranges (a-z, 0-9), or combinations
* Examples: "0-9", "a-z", "abc", "^abc", "a-zA-Z0-9"
*/
private static boolean isValidCharacterClass(String content) {
if (content.isEmpty()) {
return false;
}

// Handle negation
int startIndex = content.startsWith("^") ? 1 : 0;
if (startIndex >= content.length()) {
return false;
}

String chars = content.substring(startIndex);

// A valid character class should only contain letters, digits, hyphens, and underscores
// Examples: [0-9], [a-z], [a-zA-Z0-9_], [abc], but NOT [test.Folder dev]
// We're being conservative: spaces, dots, and other special chars disqualify it
for (char c : chars.toCharArray()) {
if (!Character.isLetterOrDigit(c) && c != '-' && c != '_') {
return false;
}
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -253,4 +253,77 @@ public void testJsonlSavesFileFilter() throws Exception {

verifyNoMoreInteractions(files);
}

@Test
public void testJsonlSavesFileFilter2() throws Exception {
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test method name testJsonlSavesFileFilter2 is not descriptive. It should clearly indicate what specific scenario is being tested. Consider renaming it to something like testJsonlSavesFileFilterWithBracketsInFolderNames or testJsonlSavesFileFilterWithSpecialCharactersInPath to make the test's purpose immediately clear.

Suggested change
public void testJsonlSavesFileFilter2() throws Exception {
public void testJsonlSavesFileFilterWithBracketsInFolderNames() throws Exception {

Copilot uses AI. Check for mistakes.
ProjectProperties pb = NewProjectPropertiesUtilBuilder.minimalBuilt().build();

// Build project with one file (id=101)
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states "Build project with one file (id=101)" but the code actually adds two files with ids 101 and 102. Update the comment to accurately reflect the test setup, for example: "Build project with two files (id=101 and id=102)".

Suggested change
// Build project with one file (id=101)
// Build project with two files (id=101 and id=102)

Copilot uses AI. Check for mistakes.
var projectFull = ProjectBuilder.emptyProject(Long.parseLong(pb.getProjectId()))
.addFile("/[test.Folder dev]/resources/js/lang/en/auth.php", "plain", 101L, null, null, "/%original_file_name%")
.addFile("/[test.Folder dev]/resources/js/lang/en/email.php", "plain", 102L, null, null, "/%original_file_name%")
.build();
projectFull.setType(Type.FILES_BASED);

ProjectClient client = mock(ProjectClient.class);
when(client.downloadFullProject(null)).thenReturn(projectFull);
when(client.listLabels()).thenReturn(List.of());

SourceString ss1 = SourceStringBuilder.standard()
.setProjectId(Long.parseLong(pb.getProjectId()))
.setIdentifiers(701L, "the-text", "manual\n\n✨ AI Context\nai-content\n✨ 🔚", "the.key", 101L)
.build();

SourceString ss2 = SourceStringBuilder.standard()
.setProjectId(Long.parseLong(pb.getProjectId()))
.setIdentifiers(702L, "the-text2", "manual\n\n✨ AI Context\nai-content2\n✨ 🔚", "the.key2", 101L)
Copy link

Copilot AI Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The source string ss2 is created with fileId=101L (the last parameter in setIdentifiers), but it's being returned by the mock when querying for source strings from file 102L. This is inconsistent.

Since the test is checking that both files (101 and 102) are processed when matching the filter /[test.Folder dev]/**/*.php, the source strings should have the correct fileId to reflect which file they belong to. Update ss2 to use fileId=102L instead of 101L.

Suggested change
.setIdentifiers(702L, "the-text2", "manual\n\n✨ AI Context\nai-content2\n✨ 🔚", "the.key2", 101L)
.setIdentifiers(702L, "the-text2", "manual\n\n✨ AI Context\nai-content2\n✨ 🔚", "the.key2", 102L)

Copilot uses AI. Check for mistakes.
.build();

when(client.listSourceString(101L, null, null, null, null, null, null))
.thenReturn(Arrays.asList(ss1));

when(client.listSourceString(102L, null, null, null, null, null, null))
.thenReturn(Arrays.asList(ss2));

FilesInterface files = mock(FilesInterface.class);
File to = new File("out.jsonl");

ContextDownloadAction action = new ContextDownloadAction(
to,
List.of("/[test.Folder dev]/**/*.php"),
null,
null,
null,
null,
null,
"jsonl",
files,
true,
false
);

action.act(Outputter.getDefault(), pb, client);

verify(client).downloadFullProject(null);
verify(client).listLabels();
verify(client).listSourceString(101L, null, null, null, null, null, null);
verify(client).listSourceString(102L, null, null, null, null, null, null);

ArgumentCaptor<InputStream> captor = ArgumentCaptor.forClass(InputStream.class);
verify(files).writeToFile(eq(to.toString()), captor.capture());

try (InputStream is = captor.getValue()) {
byte[] bytes = is.readAllBytes();
String content = new String(bytes, UTF_8);
// The saved jsonl should contain id and key
assertTrue(content.contains("\"id\":701"));
assertTrue(content.contains("\"id\":702"));
assertTrue(content.contains("\"key\":\"the.key\""));
assertTrue(content.contains("\"key\":\"the.key2\""));
assertTrue(content.contains("\"ai_context\":\"ai-content\""));
assertTrue(content.contains("\"ai_context\":\"ai-content2\""));
}

verifyNoMoreInteractions(files);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,5 +149,9 @@ public void testIsPathMatch() {
assertFalse(FileHelper.isPathMatch("src/a/b.json", "src/a/b.txt"));
assertFalse(FileHelper.isPathMatch("android-new-file2.xml", "android-new-file.xml"));
assertTrue(FileHelper.isPathMatch("android-new-file.xml", "android-new-file.xml"));
assertTrue(FileHelper.isPathMatch("/android-new-file.xml", "android-new-file.xml"));
assertTrue(FileHelper.isPathMatch("android-new-file.xml", "/android-new-file.xml"));
assertTrue(FileHelper.isPathMatch("/[te.st]/file.json", "/[te.st]/file.json"));
assertTrue(FileHelper.isPathMatch("/test/file.json", "/[a-z]*/file.json"));
}
}
Loading