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

MeindertN / RoboClerk / 19596296463

22 Nov 2025 01:38PM UTC coverage: 69.509% (-3.1%) from 72.65%
19596296463

push

github

MeindertN
Simplified metadata collection for contentcreators

2225 of 3255 branches covered (68.36%)

Branch coverage included in aggregate %.

175 of 812 new or added lines in 27 files covered. (21.55%)

354 existing lines in 26 files now uncovered.

7167 of 10257 relevant lines covered (69.87%)

88.21 hits per line

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

82.03
/RoboClerk.UnitTestFN/UnitTestFNPlugin.cs
1
using Microsoft.Extensions.DependencyInjection;
2
using RoboClerk.Core.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 TreeSitter;
10

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

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

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

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

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

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

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

68
                ScanDirectoriesForSourceFiles();
11✔
69

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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