• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MeindertN / RoboClerk / 17879799732

20 Sep 2025 12:24PM UTC coverage: 83.388% (-0.8%) from 84.166%
17879799732

push

github

MeindertN
WIP: initial conversion to treesitter for the unittestfn plugin

1859 of 2349 branches covered (79.14%)

Branch coverage included in aggregate %.

62 of 90 new or added lines in 1 file covered. (68.89%)

5776 of 6807 relevant lines covered (84.85%)

126.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.07
/RoboClerk.UnitTestFN/UnitTestFNPlugin.cs
1
using Microsoft.Extensions.DependencyInjection;
2
using RoboClerk.Configuration;
3
using System;
4
using System.Collections.Generic;
5
using System.IO;
6
using System.IO.Abstractions;
7
using System.Linq;
8
using System.Text;
9
using Tomlyn.Model;
10
using TreeSitter;
11

12
namespace RoboClerk
13
{
14
    public class UnitTestFNPlugin : SourceCodeAnalysisPluginBase
15
    {
16
        private List<string> functionMaskElements = new List<string>();
6✔
17
        private string sectionSeparator = string.Empty;
6✔
18
        private string selectedLanguage = "csharp";
6✔
19

20
        public UnitTestFNPlugin(IFileSystem fileSystem)
21
            : base(fileSystem)
6✔
22
        {
6✔
23
            SetBaseParam();
6✔
24
        }
6✔
25

26
        private void SetBaseParam()
27
        {
6✔
28
            name = "UnitTestFNPlugin";
6✔
29
            description = "A plugin that analyzes a project's source code to extract unit test information for RoboClerk using TreeSitter.";
6✔
30
        }
6✔
31

32
        public override void InitializePlugin(IConfiguration configuration)
33
        {
6✔
34
            logger.Info("Initializing the Unit Test Function Name Plugin");
6✔
35
            try
36
            {
6✔
37
                base.InitializePlugin(configuration);
6✔
38
                var config = GetConfigurationTable(configuration.PluginConfigDir, $"{name}.toml");
5✔
39

40
                // Language selection
41
                selectedLanguage = GetObjectForKey<string>(config, "Language", false) ?? "csharp";
5!
42
                
43
                var functionMask = configuration.CommandLineOptionOrDefault("FunctionMask", GetObjectForKey<string>(config, "FunctionMask", true));
5✔
44
                functionMaskElements = ParseFunctionMask(functionMask);
5✔
45
                ValidateFunctionMaskElements(functionMaskElements);
5✔
46
                sectionSeparator = configuration.CommandLineOptionOrDefault("SectionSeparator", GetObjectForKey<string>(config, "SectionSeparator", true));
4✔
47
            }
4✔
48
            catch (Exception e)
2✔
49
            {
2✔
50
                logger.Error("Error reading configuration file for Unit Test FN plugin.");
2✔
51
                logger.Error(e);
2✔
52
                throw new Exception("The Unit Test FN plugin could not read its configuration. Aborting...");
2✔
53
            }
54
            ScanDirectoriesForSourceFiles();
4✔
55
        }
4✔
56

57
        private List<string> ParseFunctionMask(string functionMask)
58
        {
5✔
59
            List<string> elements = new List<string>();
5✔
60
            bool inElement = false;
5✔
61
            StringBuilder sb = new StringBuilder();
5✔
62
            foreach (char c in functionMask)
361✔
63
            {
173✔
64
                if (c == '<' && !inElement)
173✔
65
                {
8✔
66
                    inElement = true;
8✔
67
                    if (sb.Length > 0)
8✔
68
                    {
4✔
69
                        elements.Add(sb.ToString());
4✔
70
                        sb.Clear();
4✔
71
                    }
4✔
72
                }
8✔
73
                if (inElement)
173✔
74
                {
96✔
75
                    sb.Append(c);
96✔
76
                    if (c == '>')
96✔
77
                    {
8✔
78
                        inElement = false;
8✔
79
                        elements.Add(sb.ToString());
8✔
80
                        sb.Clear();
8✔
81
                    }
8✔
82
                }
96✔
83
                else
84
                {
77✔
85
                    sb.Append(c);
77✔
86
                }
77✔
87
            }
173✔
88
            // Add any remaining content
89
            if (sb.Length > 0)
5✔
90
            {
1✔
91
                elements.Add(sb.ToString());
1✔
92
                sb.Clear();
1✔
93
            }
1✔
94
            return elements;
5✔
95
        }
5✔
96

97
        private void ValidateFunctionMaskElements(List<string> elements)
98
        {
5✔
99
            if (!elements.First().StartsWith('<'))
5✔
100
            {
1✔
101
                throw new Exception("Error in UnitTestFNPlugin, Function Mask must start with an element identifier (e.g. <IGNORE>)");
1✔
102
            }
103
            foreach (string element in elements)
36✔
104
            {
12✔
105
                if (element.StartsWith('<'))
12✔
106
                {
8✔
107
                    if (element.ToUpper() != "<PURPOSE>" &&
8!
108
                        element.ToUpper() != "<POSTCONDITION>" &&
8✔
109
                        element.ToUpper() != "<IDENTIFIER>" &&
8✔
110
                        element.ToUpper() != "<TRACEID>" &&
8✔
111
                        element.ToUpper() != "<IGNORE>")
8✔
112
                    {
×
113
                        throw new Exception($"Error in UnitTestFNPlugin, unknown function mask element found: \"{element}\"");
×
114
                    }
115
                }
8✔
116
                else
117
                {
4✔
118
                    if (element.Any(x => Char.IsWhiteSpace(x)))
42!
119
                    {
×
120
                        throw new Exception($"Error in UnitTestFNPlugin, whitespace found in an element of the function mask: \"{element}\"");
×
121
                    }
122
                }
4✔
123
            }
12✔
124
        }
4✔
125

126
        private string SeparateSection(string section)
127
        {
6✔
128
            if (sectionSeparator.ToUpper() == "CAMELCASE")
6✔
129
            {
2✔
130
                StringBuilder sb = new StringBuilder();
2✔
131
                bool first = true;
2✔
132
                //don't care if the first word is capitalized
133
                foreach (char c in section)
72✔
134
                {
33✔
135
                    if (first)
33✔
136
                    {
2✔
137
                        sb.Append(c);
2✔
138
                        first = false;
2✔
139
                    }
2✔
140
                    else
141
                    {
31✔
142
                        if (Char.IsUpper(c))
31✔
143
                        {
7✔
144
                            sb.Append(' ');
7✔
145
                            sb.Append(c);
7✔
146
                        }
7✔
147
                        else
148
                        {
24✔
149
                            sb.Append(c);
24✔
150
                        }
24✔
151
                    }
31✔
152
                }
33✔
153
                return sb.ToString();
2✔
154
            }
155
            else
156
            {
4✔
157
                return section.Replace(sectionSeparator, " ");
4✔
158
            }
159
        }
6✔
160

161
        private List<(string, string)> ApplyFunctionNameMask(string functionName)
162
        {
6✔
163
            List<(string, string)> resultingElements = new List<(string, string)>();
6✔
164
            bool foundMatch = true;
6✔
165
            
166
            // Check if the function name matches the non-element parts of the mask
167
            foreach (var functionMaskElement in functionMaskElements)
54✔
168
            {
18✔
169
                if (!functionMaskElement.StartsWith('<'))
18✔
170
                {
6✔
171
                    foundMatch = foundMatch && functionName.Contains(functionMaskElement);
6!
172
                }
6✔
173
            }
18✔
174
            
175
            if (foundMatch)
6✔
176
            {
3✔
177
                string remainingFunctionName = functionName;
3✔
178
                for (int i = 1; i < functionMaskElements.Count; i += 2)
12✔
179
                {
3✔
180
                    if (remainingFunctionName == string.Empty)
3!
181
                    {
×
182
                        foundMatch = false;
×
183
                        break;
×
184
                    }
185
                    if (functionMaskElements[i].StartsWith('<'))
3!
186
                    {
×
187
                        throw new Exception("Error in UnitTestFNPlugin element identifier in unexpected position. Check FunctionMask.");
×
188
                    }
189
                    var items = remainingFunctionName.Split(functionMaskElements[i]);
3✔
190
                    resultingElements.Add((functionMaskElements[i - 1], items[0]));
3✔
191
                    if (items.Length - 1 != 0)
3!
192
                    {
3✔
193
                        remainingFunctionName = String.Join(functionMaskElements[i], items, 1, items.Length - 1);
3✔
194
                    }
3✔
195
                    else
196
                    {
×
NEW
197
                        remainingFunctionName = string.Empty;
×
NEW
198
                    }
×
199
                }
3✔
200
                
201
                // Handle the case where there's a final element after the last separator
202
                if (!string.IsNullOrEmpty(remainingFunctionName) && functionMaskElements.Count >= 2)
3!
203
                {
3✔
204
                    var lastElementIndex = functionMaskElements.Count - 1;
3✔
205
                    if (functionMaskElements[lastElementIndex].StartsWith('<'))
3✔
206
                    {
3✔
207
                        resultingElements.Add((functionMaskElements[lastElementIndex], remainingFunctionName));
3✔
208
                    }
3✔
209
                }
3✔
210
            }
3✔
211
            if (!foundMatch)
6✔
212
            {
3✔
213
                resultingElements.Clear();
3✔
214
            }
3✔
215
            return resultingElements;
6✔
216
        }
6✔
217

218
        private void AddUnitTest(List<(string, string)> els, string fileName, int lineNumber, string functionName)
219
        {
3✔
220
            var unitTest = new UnitTestItem();
3✔
221
            bool identified = false;
3✔
222
            string shortFileName = Path.GetFileName(fileName);
3✔
223
            foreach (var el in els)
21✔
224
            {
6✔
225
                switch (el.Item1.ToUpper())
6!
226
                {
227
                    case "<PURPOSE>": unitTest.UnitTestPurpose = SeparateSection(el.Item2); break;
6✔
228
                    case "<POSTCONDITION>": unitTest.UnitTestAcceptanceCriteria = SeparateSection(el.Item2); break;
6✔
229
                    case "<IDENTIFIER>": unitTest.ItemID = SeparateSection(el.Item2); identified = true; break;
×
230
                    case "<TRACEID>": unitTest.AddLinkedItem(new ItemLink(el.Item2, ItemLinkType.UnitTests)); break;
×
231
                    case "<IGNORE>": break;
×
232
                    default: throw new Exception($"Unknown element identifier in FunctionMask: {el.Item1.ToUpper()}");
×
233
                }
234
            }
6✔
235
            unitTest.UnitTestFileName = shortFileName;
3✔
236
            unitTest.UnitTestFunctionName = functionName;
3✔
237
            if (!identified)
3✔
238
            {
3✔
239
                unitTest.ItemID = $"{shortFileName}:{lineNumber}";
3✔
240
            }
3✔
241
            if (gitRepo != null && !gitRepo.GetFileLocallyUpdated(fileName))
3!
242
            {
×
243
                //if gitInfo is not null, this means some item data elements should be collected through git
244
                unitTest.ItemLastUpdated = gitRepo.GetFileLastUpdated(fileName);
×
245
                unitTest.ItemRevision = gitRepo.GetFileVersion(fileName);
×
246
            }
×
247
            else
248
            {
3✔
249
                //the last time the local file was updated is our best guess
250
                unitTest.ItemLastUpdated = File.GetLastWriteTime(fileName);
3✔
251
                unitTest.ItemRevision = File.GetLastWriteTime(fileName).ToString("yyyy/MM/dd HH:mm:ss");
3✔
252
            }
3✔
253
            if (unitTests.FindIndex(x => x.ItemID == unitTest.ItemID) != -1)
4!
254
            {
×
255
                throw new Exception($"Duplicate unit test identifier detected in {shortFileName} in the annotation starting on line {lineNumber}. Check other unit tests to ensure all unit tests have a unique identifier.");
×
256
            }
257
            unitTests.Add(unitTest);
3✔
258
        }
3✔
259

260
        public override void RefreshItems()
261
        {
3✔
262
            foreach (var sourceFile in sourceFiles)
15✔
263
            {
3✔
264
                var text = fileSystem.File.ReadAllText(sourceFile);
3✔
265
                FindAndProcessFunctions(text, sourceFile);
3✔
266
            }
3✔
267
        }
3✔
268

269
        private void FindAndProcessFunctions(string sourceText, string filename)
270
        {
3✔
271
            var languageId = GetTreeSitterLanguageId(selectedLanguage);
3✔
272
            
273
            using var language = new Language(languageId);
3✔
274
            using var parser = new Parser(language);
3✔
275
            using var tree = parser.Parse(sourceText);
3✔
276

277
            var queryString = GetQueryForLanguage(selectedLanguage);
3✔
278
            using var query = new Query(language, queryString);
3✔
279
            var exec = query.Execute(tree.RootNode);
3✔
280

281
            // Process all methods found by TreeSitter
282
            var processedMethods = new HashSet<(string methodName, int line)>();
3✔
283

284
            foreach (var match in exec.Matches)
21✔
285
            {
6✔
286
                string methodName = string.Empty;
6✔
287
                int methodLine = 0;
6✔
288

289
                foreach (var cap in match.Captures)
24✔
290
                {
6✔
291
                    if (cap.Name == "method_name")
6!
292
                    {
6✔
293
                        methodName = cap.Node.Text;
6✔
294
                        methodLine = (int)cap.Node.StartPosition.Row + 1;
6✔
295
                        break;
6✔
296
                    }
297
                }
×
298

299
                if (!string.IsNullOrEmpty(methodName))
6✔
300
                {
6✔
301
                    var methodKey = (methodName, methodLine);
6✔
302
                    if (processedMethods.Contains(methodKey))
6✔
NEW
303
                        continue;
×
304
                    processedMethods.Add(methodKey);
6✔
305
                                        
306
                    // Try to apply the function name mask
307
                    var els = ApplyFunctionNameMask(methodName);
6✔
308
                    if (els.Count > 0)
6✔
309
                    {
3✔
310
                        AddUnitTest(els, filename, methodLine, methodName);
3✔
311
                    }
3✔
312
                }
6✔
313
            }
6✔
314
        }
6✔
315

316
        private string GetTreeSitterLanguageId(string lang) => lang.ToLowerInvariant() switch
3!
317
        {
3✔
318
            "c#" or "csharp" or "cs" => "C_SHARP",
3✔
NEW
319
            "java" => "JAVA",
×
NEW
320
            "python" or "py" => "PYTHON",
×
NEW
321
            "typescript" or "ts" => "TYPESCRIPT",
×
NEW
322
            "javascript" or "js" => "TYPESCRIPT", // use TS grammar to parse JS
×
NEW
323
            _ => "C_SHARP"
×
324
        };
3✔
325

326
        private string GetQueryForLanguage(string lang)
327
        {
3✔
328
            return lang.ToLowerInvariant() switch
3!
329
            {
3✔
330
                "c#" or "csharp" or "cs" => @"
3✔
331
(method_declaration
3✔
332
  name: (identifier) @method_name
3✔
333
)",
3✔
334

3✔
NEW
335
                "java" => @"
×
NEW
336
(method_declaration
×
NEW
337
  name: (identifier) @method_name
×
NEW
338
)",
×
339

3✔
NEW
340
                "python" or "py" => @"
×
NEW
341
(function_definition 
×
NEW
342
  name: (identifier) @method_name
×
NEW
343
)",
×
344

3✔
NEW
345
                "typescript" or "ts" or "javascript" or "js" => @"
×
NEW
346
(method_definition
×
NEW
347
  name: (identifier) @method_name
×
NEW
348
)
×
NEW
349

×
NEW
350
(function_declaration
×
NEW
351
  name: (identifier) @method_name
×
NEW
352
)",
×
353

3✔
NEW
354
                _ => @"
×
NEW
355
(method_declaration
×
NEW
356
  name: (identifier) @method_name
×
NEW
357
)"
×
358
            };
3✔
359
        }
3✔
360
    }
361
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc