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

MeindertN / RoboClerk / 19313674688

12 Nov 2025 10:19PM UTC coverage: 81.831% (-2.2%) from 84.026%
19313674688

push

github

web-flow
Merge pull request #79 from MeindertN/V1.6.0

jumping to V1.7.0

2069 of 2635 branches covered (78.52%)

Branch coverage included in aggregate %.

1293 of 1710 new or added lines in 32 files covered. (75.61%)

17 existing lines in 5 files now uncovered.

6322 of 7619 relevant lines covered (82.98%)

189.57 hits per line

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

82.0
/RoboClerk.UnitTestFN/UnitTestFNPlugin.cs
1
using RoboClerk.Configuration;
2
using System;
3
using System.Collections.Generic;
4
using System.IO;
5
using System.IO.Abstractions;
6
using System.Linq;
7
using System.Text;
8
using TreeSitter;
9

10
namespace RoboClerk
11
{
12
    public class UnitTestFNPlugin : SourceCodeAnalysisPluginBase
13
    {
14
        private string selectedLanguage = "csharp";
13✔
15

16
        public UnitTestFNPlugin(IFileSystem fileSystem)
17
            : base(fileSystem)
13✔
18
        {
13✔
19
            SetBaseParam();
13✔
20
        }
13✔
21

22
        private void SetBaseParam()
23
        {
13✔
24
            name = "UnitTestFNPlugin";
13✔
25
            description = "A plugin that analyzes a project's source code to extract unit test information for RoboClerk using TreeSitter.";
13✔
26
        }
13✔
27

28
        public override void InitializePlugin(IConfiguration configuration)
29
        {
13✔
30
            logger.Info("Initializing the Unit Test Function Name Plugin");
13✔
31
            try
32
            {
13✔
33
                // Base class handles both TestConfigurations and legacy format
34
                base.InitializePlugin(configuration);
13✔
35

36
                // For backward compatibility, check if we have configurations or legacy format
37
                if (TestConfigurations.Count == 0)
12!
NEW
38
                {
×
NEW
39
                    throw new Exception("No test configurations found. At least one TestConfiguration is required for UnitTestFN plugin.");
×
40
                }
41

42
                // Validate that all configurations have required parameters
43
                foreach (var testConfig in TestConfigurations)
59✔
44
                {
12✔
45
                    var functionMask = configuration.CommandLineOptionOrDefault("FunctionMask", testConfig.GetValue<string>("FunctionMask"));
12✔
46
                    if (string.IsNullOrEmpty(functionMask))
12!
NEW
47
                    {
×
NEW
48
                        throw new Exception($"FunctionMask is required for test configuration '{testConfig.Project}'. Please ensure FunctionMask is specified in the TestConfiguration.");
×
49
                    }
50
                    
51
                    var functionMaskElements = ParseFunctionMask(functionMask);
12✔
52
                    ValidateFunctionMaskElements(functionMaskElements);
12✔
53
                    
54
                    var sectionSeparator = configuration.CommandLineOptionOrDefault("SectionSeparator", testConfig.GetValue<string>("SectionSeparator"));
11✔
55
                    if (string.IsNullOrEmpty(sectionSeparator))
11!
NEW
56
                    {
×
NEW
57
                        throw new Exception($"SectionSeparator is required for test configuration '{testConfig.Project}'. Please ensure SectionSeparator is specified in the TestConfiguration.");
×
58
                    }
59
                }
11✔
60

61
                // Get the primary configuration (first one for single-config scenarios)
62
                var primaryConfig = TestConfigurations[0];
11✔
63
                
64
                // Try to get plugin-specific fields from command line first, then from configuration
65
                selectedLanguage = configuration.CommandLineOptionOrDefault("Language", primaryConfig.GetValue<string>("Language", "csharp"));
11✔
66

67
                ScanDirectoriesForSourceFiles();
11✔
68

69
                logger.Debug($"Initialized UnitTestFN plugin with {TestConfigurations.Count} test configurations, primary language: {selectedLanguage}");
11✔
70
            }
11✔
71
            catch (Exception e)
2✔
72
            {
2✔
73
                logger.Error($"Error reading configuration file for Unit Test FN plugin: {e.Message}");
2✔
74
                logger.Error(e);
2✔
75
                throw new Exception("The Unit Test FN plugin could not read its configuration. Aborting...");
2✔
76
            }
77
        }
11✔
78

79
        private List<string> ParseFunctionMask(string functionMask)
80
        {
22✔
81
            List<string> elements = new List<string>();
22✔
82
            bool inElement = false;
22✔
83
            StringBuilder sb = new StringBuilder();
22✔
84
            foreach (char c in functionMask)
1,752✔
85
            {
843✔
86
                if (c == '<' && !inElement)
843✔
87
                {
48✔
88
                    inElement = true;
48✔
89
                    if (sb.Length > 0)
48✔
90
                    {
27✔
91
                        elements.Add(sb.ToString());
27✔
92
                        sb.Clear();
27✔
93
                    }
27✔
94
                }
48✔
95
                if (inElement)
843✔
96
                {
562✔
97
                    sb.Append(c);
562✔
98
                    if (c == '>')
562✔
99
                    {
48✔
100
                        inElement = false;
48✔
101
                        elements.Add(sb.ToString());
48✔
102
                        sb.Clear();
48✔
103
                    }
48✔
104
                }
562✔
105
                else
106
                {
281✔
107
                    sb.Append(c);
281✔
108
                }
281✔
109
            }
843✔
110
            // Add any remaining content
111
            if (sb.Length > 0)
22✔
112
            {
1✔
113
                elements.Add(sb.ToString());
1✔
114
                sb.Clear();
1✔
115
            }
1✔
116
            return elements;
22✔
117
        }
22✔
118

119
        private void ValidateFunctionMaskElements(List<string> elements)
120
        {
12✔
121
            if (!elements.First().StartsWith('<'))
12✔
122
            {
1✔
123
                throw new Exception("Error in UnitTestFNPlugin, Function Mask must start with an element identifier (e.g. <IGNORE>)");
1✔
124
            }
125
            foreach (string element in elements)
111✔
126
            {
39✔
127
                if (element.StartsWith('<'))
39✔
128
                {
25✔
129
                    if (element.ToUpper() != "<PURPOSE>" &&
25!
130
                        element.ToUpper() != "<POSTCONDITION>" &&
25✔
131
                        element.ToUpper() != "<IDENTIFIER>" &&
25✔
132
                        element.ToUpper() != "<TRACEID>" &&
25✔
133
                        element.ToUpper() != "<IGNORE>")
25✔
134
                    {
×
135
                        throw new Exception($"Error in UnitTestFNPlugin, unknown function mask element found: \"{element}\"");
×
136
                    }
137
                }
25✔
138
                else
139
                {
14✔
140
                    if (element.Any(x => Char.IsWhiteSpace(x)))
140!
141
                    {
×
142
                        throw new Exception($"Error in UnitTestFNPlugin, whitespace found in an element of the function mask: \"{element}\"");
×
143
                    }
144
                }
14✔
145
            }
39✔
146
        }
11✔
147

148
        private string SeparateSection(string section, string sectionSeparator)
149
        {
42✔
150
            if (sectionSeparator.ToUpper() == "CAMELCASE")
42✔
151
            {
2✔
152
                StringBuilder sb = new StringBuilder();
2✔
153
                bool first = true;
2✔
154
                //don't care if the first word is capitalized
155
                foreach (char c in section)
72✔
156
                {
33✔
157
                    if (first)
33✔
158
                    {
2✔
159
                        sb.Append(c);
2✔
160
                        first = false;
2✔
161
                    }
2✔
162
                    else
163
                    {
31✔
164
                        if (Char.IsUpper(c))
31✔
165
                        {
7✔
166
                            sb.Append(' ');
7✔
167
                            sb.Append(c);
7✔
168
                        }
7✔
169
                        else
170
                        {
24✔
171
                            sb.Append(c);
24✔
172
                        }
24✔
173
                    }
31✔
174
                }
33✔
175
                return sb.ToString();
2✔
176
            }
177
            else
178
            {
40✔
179
                return section.Replace(sectionSeparator, " ");
40✔
180
            }
181
        }
42✔
182

183
        private List<(string, string)> ApplyFunctionNameMask(string functionName, List<string> functionMaskElements)
184
        {
27✔
185
            List<(string, string)> resultingElements = new List<(string, string)>();
27✔
186
            
187
            // Check if the function name matches the non-element parts of the mask
188
            bool foundMatch = true;
27✔
189
            foreach (var functionMaskElement in functionMaskElements)
246✔
190
            {
86✔
191
                if (!functionMaskElement.StartsWith('<'))
86✔
192
                {
33✔
193
                    if (!functionName.Contains(functionMaskElement))
33✔
194
                    {
7✔
195
                        foundMatch = false;
7✔
196
                        break;
7✔
197
                    }
198
                }
26✔
199
            }
79✔
200
            
201
            if (foundMatch)
27✔
202
            {
20✔
203
                string remainingFunctionName = functionName;
20✔
204
                
205
                // Process pairs: identifier, separator, identifier, separator, etc.
206
                for (int i = 0; i < functionMaskElements.Count; i++)
144✔
207
                {
72✔
208
                    if (functionMaskElements[i].StartsWith('<'))
72✔
209
                    {
46✔
210
                        // This is an element identifier
211
                        if (i + 1 < functionMaskElements.Count && !functionMaskElements[i + 1].StartsWith('<'))
46✔
212
                        {
26✔
213
                            // Next element is a separator
214
                            var separator = functionMaskElements[i + 1];
26✔
215
                            var separatorIndex = remainingFunctionName.IndexOf(separator);
26✔
216
                            
217
                            if (separatorIndex >= 0)
26!
218
                            {
26✔
219
                                var extractedContent = remainingFunctionName.Substring(0, separatorIndex);
26✔
220
                                resultingElements.Add((functionMaskElements[i], extractedContent));
26✔
221
                                remainingFunctionName = remainingFunctionName.Substring(separatorIndex + separator.Length);
26✔
222
                            }
26✔
223
                            else
NEW
224
                            {
×
NEW
225
                                foundMatch = false;
×
NEW
226
                                break;
×
227
                            }
228
                        }
26✔
229
                        else
230
                        {
20✔
231
                            // This is the last element - take all remaining content
232
                            resultingElements.Add((functionMaskElements[i], remainingFunctionName));
20✔
233
                            break;
20✔
234
                        }
235
                    }
26✔
236
                }
52✔
237
            }
20✔
238
            
239
            if (!foundMatch)
27✔
240
            {
7✔
241
                resultingElements.Clear();
7✔
242
            }
7✔
243
            
244
            return resultingElements;
27✔
245
        }
27✔
246

247
        private void AddUnitTest(List<(string, string)> els, string fileName, int lineNumber, string functionName, string sectionSeparator)
248
        {
20✔
249
            var unitTest = new UnitTestItem();
20✔
250
            bool identified = false;
20✔
251
            string shortFileName = Path.GetFileName(fileName);
20✔
252
            foreach (var el in els)
152✔
253
            {
46✔
254
                switch (el.Item1.ToUpper())
46!
255
                {
256
                    case "<PURPOSE>": unitTest.UnitTestPurpose = SeparateSection(el.Item2, sectionSeparator); break;
40✔
257
                    case "<POSTCONDITION>": unitTest.UnitTestAcceptanceCriteria = SeparateSection(el.Item2, sectionSeparator); break;
40✔
258
                    case "<IDENTIFIER>": unitTest.ItemID = SeparateSection(el.Item2, sectionSeparator); identified = true; break;
6✔
259
                    case "<TRACEID>": unitTest.AddLinkedItem(new ItemLink(el.Item2, ItemLinkType.UnitTests)); break;
4✔
260
                    case "<IGNORE>": break;
2✔
261
                    default: throw new Exception($"Unknown element identifier in FunctionMask: {el.Item1.ToUpper()}");
×
262
                }
263
            }
46✔
264
            unitTest.UnitTestFileName = shortFileName;
20✔
265
            unitTest.UnitTestFunctionName = functionName;
20✔
266
            if (!identified)
20✔
267
            {
18✔
268
                unitTest.ItemID = $"{shortFileName}:{lineNumber}";
18✔
269
            }
18✔
270
            if (gitRepo != null && !gitRepo.GetFileLocallyUpdated(fileName))
20!
271
            {
×
272
                //if gitInfo is not null, this means some item data elements should be collected through git
273
                unitTest.ItemLastUpdated = gitRepo.GetFileLastUpdated(fileName);
×
274
                unitTest.ItemRevision = gitRepo.GetFileVersion(fileName);
×
275
            }
×
276
            else
277
            {
20✔
278
                //the last time the local file was updated is our best guess
279
                unitTest.ItemLastUpdated = File.GetLastWriteTime(fileName);
20✔
280
                unitTest.ItemRevision = File.GetLastWriteTime(fileName).ToString("yyyy/MM/dd HH:mm:ss");
20✔
281
            }
20✔
282
            if (unitTests.FindIndex(x => x.ItemID == unitTest.ItemID) != -1)
34!
283
            {
×
284
                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.");
×
285
            }
286
            unitTests.Add(unitTest);
20✔
287
        }
20✔
288

289
        public override void RefreshItems()
290
        {
10✔
291
            // Use the optimized approach: iterate through configurations and their associated files
292
            foreach (var testConfig in TestConfigurations)
50✔
293
            {
10✔
294
                if (testConfig.SourceFiles.Count == 0)
10!
NEW
295
                {
×
NEW
296
                    logger.Warn($"No source files found for configuration '{testConfig.Project}' (Language: {testConfig.Language})");
×
NEW
297
                    continue;
×
298
                }
299
                
300
                logger.Debug($"Processing {testConfig.SourceFiles.Count} files for configuration '{testConfig.Project}' (Language: {testConfig.Language})");
10✔
301
                
302
                // Get configuration-specific parameters for this test configuration
303
                var configLanguage = testConfig.GetValue<string>("Language", selectedLanguage);
10✔
304
                var functionMask = testConfig.GetValue<string>("FunctionMask");
10✔
305
                var sectionSeparator = testConfig.GetValue<string>("SectionSeparator");
10✔
306
                
307
                // Parse function mask for this configuration
308
                var functionMaskElements = ParseFunctionMask(functionMask);
10✔
309
                
310
                var languageId = GetTreeSitterLanguageId(configLanguage);
10✔
311
                
312
                // Load language resources once per configuration
313
                using var language = new Language(languageId);
10✔
314
                using var parser = new Parser(language);
10✔
315
                
316
                var queryString = GetQueryForLanguage(configLanguage);
10✔
317
                using var query = new Query(language, queryString);
10✔
318
                
319
                // Process all files for this configuration with the same language resources
320
                foreach (var sourceFile in testConfig.SourceFiles)
50✔
321
                {
10✔
322
                    try
323
                    {
10✔
324
                        var text = fileSystem.File.ReadAllText(sourceFile);
10✔
325
                        FindAndProcessFunctions(text, sourceFile, parser, query, functionMaskElements, sectionSeparator);
10✔
326
                    }
10✔
NEW
327
                    catch (Exception e)
×
NEW
328
                    {
×
NEW
329
                        logger.Error($"Error processing file {sourceFile}: {e.Message}");
×
NEW
330
                        throw;
×
331
                    }
332
                }
10✔
333
            }
10✔
334
        }
10✔
335

336
        private void FindAndProcessFunctions(string sourceText, string filename, Parser parser, Query query, List<string> functionMaskElements, string sectionSeparator)
337
        {
10✔
338
            using var tree = parser.Parse(sourceText);
10✔
339
            var exec = query.Execute(tree.RootNode);
10✔
340

341
            // Process all methods found by TreeSitter
342
            var processedMethods = new HashSet<(string methodName, int line)>();
10✔
343

344
            foreach (var match in exec.Matches)
84✔
345
            {
27✔
346
                string methodName = string.Empty;
27✔
347
                int methodLine = 0;
27✔
348

349
                foreach (var cap in match.Captures)
108✔
350
                {
27✔
351
                    if (cap.Name == "method_name")
27!
352
                    {
27✔
353
                        methodName = cap.Node.Text;
27✔
354
                        methodLine = (int)cap.Node.StartPosition.Row + 1;
27✔
355
                        break;
27✔
356
                    }
357
                }
×
358

359
                if (!string.IsNullOrEmpty(methodName))
27✔
360
                {
27✔
361
                    var methodKey = (methodName, methodLine);
27✔
362
                    if (processedMethods.Contains(methodKey))
27✔
NEW
363
                        continue;
×
364
                    processedMethods.Add(methodKey);
27✔
365
                                        
366
                    // Try to apply the function name mask
367
                    var els = ApplyFunctionNameMask(methodName, functionMaskElements);
27✔
368
                    if (els.Count > 0)
27✔
369
                    {
20✔
370
                        AddUnitTest(els, filename, methodLine, methodName, sectionSeparator);
20✔
371
                    }
20✔
372
                }
27✔
373
            }
27✔
374
        }
20✔
375

376
        private string GetTreeSitterLanguageId(string lang) => lang.ToLowerInvariant() switch
10!
377
        {
10✔
378
            "c#" or "csharp" or "cs" => "C_SHARP",
5✔
379
            "java" => "JAVA",
1✔
380
            "python" or "py" => "PYTHON",
2✔
381
            "typescript" or "ts" => "TYPESCRIPT",
1✔
382
            "javascript" or "js" => "TYPESCRIPT", // use TS grammar to parse JS
1✔
NEW
383
            _ => "C_SHARP"
×
384
        };
10✔
385

386
        private string GetQueryForLanguage(string lang)
387
        {
10✔
388
            return lang.ToLowerInvariant() switch
10!
389
            {
10✔
390
                "c#" or "csharp" or "cs" => @"
5✔
391
(method_declaration
5✔
392
  name: (identifier) @method_name
5✔
393
)",
5✔
394

10✔
395
                "java" => @"
1✔
396
(method_declaration
1✔
397
  name: (identifier) @method_name
1✔
398
)",
1✔
399

10✔
400
                "python" or "py" => @"
2✔
401
(function_definition 
2✔
402
  name: (identifier) @method_name
2✔
403
)",
2✔
404

10✔
405
                "typescript" or "ts" or "javascript" or "js" => @"
2✔
406
; One pattern that matches class methods and standalone functions
2✔
407
(
2✔
408
  [
2✔
409
    (method_definition
2✔
410
      name: (_) @method_name      ; property_identifier, string, number, computed — all OK
2✔
411
    )
2✔
412
    (function_declaration
2✔
413
      name: (identifier) @method_name
2✔
414
    )
2✔
415
  ]
2✔
416
)
2✔
417
",
2✔
418

10✔
NEW
419
                _ => @"
×
NEW
420
(method_declaration
×
NEW
421
  name: (identifier) @method_name
×
NEW
422
)"
×
423
            };
10✔
424
        }
10✔
425
    }
426
}
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