Fix/MultiFile pattern (Issue #696) - #697
Conversation
- Fixed/MultiFile rollover patterns with an index before the extension:
- RolloverFilenameBuilder: the parser was dropping literal text after the final placeholder, including .log.
- *$J(.).log now correctly expands: app.log → app.1.log → app.2.log.
- Updated MultiFile mask dialog legend:
- Fixed incorrectly displayed <date> and <prefix> placeholders in English and German resources.
- Added both usage examples:
*$J(.) → app.log, app.log.1, app.log.2
*$J(.).log → app.log, app.1.log, app.2.log
- Added the examples in English, German, and Chinese.
- Enlarged the dialog and syntax-help label so the date-format lines are no longer clipped.
- Fixed/Items in menu File/Multifile -> "Enable MultiFile" and "Multi file mask...", were always greyed out. Now they are displayed normally after the .log file is open.
- removed the BackColor and ForeColor assignments for both:
- multiFileEnabledStripMenuItem
- multifileMaskToolStripMenuItem
- Added regression coverage:
- Filename-builder tests for app.1.log and app.2.log.
- Reader-level test that loads app.log, app.1.log, and app.2.log in correct order using *$J(.).log.
- Kept the existing *$J(.) coverage for app.log.1 / app.log.2.
Verification:
- Full test suite: 1064 passed, 0 failed, 7 skipped.
- Resource project build: passed with 0 warnings, 0 errors.
| } | ||
| } | ||
|
|
||
| _ = result.Append(Regex.Escape(segment.ToString())); |
There was a problem hiding this comment.
Duplicated Code — [RolloverFilenameBuilder.cs:241] is the exact shape of the state-0 flush at line 202. A tiny local FlushEscaped() would let both share it
There was a problem hiding this comment.
Added FlushEscaped() to share duplicated escaping code.
| } | ||
| } | ||
|
|
||
| _ = result.Append(Regex.Escape(segment.ToString())); |
There was a problem hiding this comment.
Index parsing from a rotated file is still broken. Spec: "app.1.log → Rotated log (Index 1)". RolloverFilenameHandler calls SetFileName on whatever file the user opens; for $J(.).log the regex .(?'index'\d*).log has a greedy .* that swallows the digit in app.1.log, so Index parses as 0 and BuildFileName would produce app.1.1.log. Grouping only works when the active file (app.log) is opened. The same flaw pre-exists for *$J(.) with app.log.1, so it's parity rather than a regression — so the index mapping is only partially delivered.
There was a problem hiding this comment.
- fixed parsing of
$J(<prefix>)from rotated files, so bothapp.log.1andapp.1.logcorrectly identify index 1. - made wildcard matching non-greedy and anchored the generated regex to the complete filename.
- updated rebuilding logic to remove an existing prefix plus index together before inserting the new index.
- fixed $D(...) parsing to use positions in the escaped format string, preventing errors when a literal regex character such as . appears before $D
Added coverage for opening:
- engine.log.1 with
*$J(.)→ builds engine.log.2 - engine.1.log with
*$J(.).log→ builds engine.2.log
| [TestCase("engine1.log", "engine2.log","engine$J.log")] | ||
| [TestCase("engine.log", "engine.log.1","*$J(.)")] | ||
| [TestCase("engine.log", "engine.1.log", "*$J(.).log")] | ||
| [TestCase("engine_2010-06-12.log", "engine_2010-06-12.log.1", "*$D(yyyy-MM-dd).log$J(.)")] |
There was a problem hiding this comment.
$I untested. For "$J, $I" support, the fix itself generalizes, but no test covers $I or $D followed by a trailing literal (e.g. *$D(yyyy-MM-dd)$J(.).log).
There was a problem hiding this comment.
Added cases which cover:
- $I with a trailing literal .log, starting from a rotated file.
- $D plus $J(.) followed by a trailing .log, also starting from a rotated file.
| [TestCase("engine1.log", "engine2.log","engine$J.log")] | ||
| [TestCase("engine.log", "engine.log.1","*$J(.)")] | ||
| [TestCase("engine.log", "engine.1.log", "*$J(.).log")] | ||
| [TestCase("engine_2010-06-12.log", "engine_2010-06-12.log.1", "*$D(yyyy-MM-dd).log$J(.)")] |
There was a problem hiding this comment.
ParseFormatString computes datePos from the original string but edits the escaped one — a literal with regex metachars before $D (e.g. app.$D(yyyy-MM-dd)) corrupts the regex. Not introduced here, but the new trailing-literal support makes such patterns likelier.
There was a problem hiding this comment.
Fixed by locating and parsing $D(...) in the escaped format string. Offsets remain correct when literal regex metacharacters appear before the date placeholder.
Added a regression test for app.$D(yyyy-MM-dd).log that verifies date parsing and rebuilding.
Fixed by locating and parsing $D(...) in the escaped format string. Offsets remain correct when literal regex metacharacters appear before the date placeholder. Added a regression test for app.$D(yyyy-MM-dd).log that verifies date parsing and rebuilding. Added cases which cover: - $I with a trailing literal .log, starting from a rotated file. - $D plus $J(.) followed by a trailing .log, also starting from a rotated file.
- Added FlushEscaped() to share duplicated escaping code.
- Fixed parsing of $J(<prefix>) from rotated files, so both:
- app.log.1
- app.1.log
correctly identify index 1.
- Made wildcard matching non-greedy and anchored the generated regex to the complete filename.
- Updated rebuilding logic to remove an existing prefix plus index together before inserting the new index.
- Fixed $D(...) parsing to use positions in the escaped format string, preventing errors when a literal regex character such as . appears before $D.
|
Check please |
…mid-filename-index-tags-(app.1.log)
| { | ||
| _ = result.Append(Regex.Escape(segment.ToString())); | ||
| segment = new StringBuilder(); | ||
| } |
There was a problem hiding this comment.
extract it to a real function not a function in a function, this only leads to unreadable code
There was a problem hiding this comment.
Moved the helper out of EscapeNonvarRegions() into a private class method. (1)
| StringBuilder result = new(); | ||
| StringBuilder segment = new(); | ||
|
|
||
| void FlushEscaped () |
There was a problem hiding this comment.
FlushEscaped() covers only case 0, while case 1 and case 3 still inline _ = result.Append(segment); segment = new StringBuilder();.
There was a problem hiding this comment.
Added two shared helpers: one appends an escaped literal segment and one appends a placeholder segment. Both also clear the segment, so states 0, 1, and 3 now use the same append & clear behavior. (2)
| if (_indexGroup != null && _indexGroup.Success) | ||
| { | ||
| fileName = fileName.Remove(_indexGroup.Index, _indexGroup.Length); | ||
| var indexPosition = _indexGroup.Index; |
There was a problem hiding this comment.
Hidden invariant / Feature Envy — indexPosition = _condGroup.Index; indexLength += _condGroup.Length; silently assumes cond is contiguous and immediately precedes index — an invariant set 60 lines away in ParseFormatString. Uncommented; changing either regex breaks the other.
There was a problem hiding this comment.
Removed the separate prefix-position calculation. The generated regex now captures the complete index part, including its prefix when present, in one indexPart group. Rebuilding removes and replaces that explicit group directly. (3)
| } | ||
|
|
||
| [Test] | ||
| public void BuildFileName_DatePatternAfterRegexMetacharacter_IncrementsDate () |
There was a problem hiding this comment.
BuildFileName_X_Y while the file's existing methods are TestFilenameAnd1/2.
There was a problem hiding this comment.
Renamed to TestFilenameDateAfterRegexMetacharacter
(8)
| _hideZeroIndex = fmt.Contains("$J", StringComparison.Ordinal); | ||
| fmt = fmt.Replace("$I", "(?'index'[\\d]+)", StringComparison.Ordinal); | ||
| fmt = fmt.Replace("$J", "(?'index'[\\d]*)", StringComparison.Ordinal); | ||
| var optionalIndexPattern = _condContent != null |
There was a problem hiding this comment.
optionalIndexPattern reads as "the pattern is optional";
There was a problem hiding this comment.
Renamed the pattern variables to indexPattern and conditionalIndexPattern to describe what they match rather than suggesting that the whole pattern is optional. (4)
|
|
||
| File.Copy(Path.Combine(_testDataDirectory, "app.log"), _logFile); | ||
| File.Copy(Path.Combine(_testDataDirectory, "app.log.1"), _logFile + ".1"); | ||
| File.Copy(Path.Combine(_testDataDirectory, "app.1.log"), Path.Combine(_testDirectory, "app.1.log")); |
There was a problem hiding this comment.
SetUp copies app.1.log/app.2.log unconditionally, so all four tests now see two extra files though only the new one needs them.
There was a problem hiding this comment.
Two copies moved to SingleFileCtor_MultiFileTrue_LoadsIndexBeforeExtension().
(9)
| fmt = fmt.Replace("*", ".*", StringComparison.Ordinal); | ||
| fmt = fmt.Replace("*", ".*?", StringComparison.Ordinal); | ||
| _hideZeroIndex = fmt.Contains("$J", StringComparison.Ordinal); | ||
| fmt = fmt.Replace("$I", "(?'index'[\\d]+)", StringComparison.Ordinal); |
There was a problem hiding this comment.
"Allow index tags ($J, $I) to appear before a fixed file extension suffix." RolloverFilenameBuilder.cs:189 still maps $I → (?'index'[\d]+); only $J gets the prefix-aware alternation (:190-193). So *$I.log cannot match the active file app.log — SetFileName fails, IsIndexPattern stays false, no chain is built. The one new $I test (RollingNameTest.cs:36, engine1.log→engine2.log) starts from a numbered file and never exercises the failing case.
There was a problem hiding this comment.
Updated $I to match an unnumbered active file as well as numbered rotated files.
Added coverage for app.log becoming app1.log after the index is incremented.
(5)
| $D(&lt;date&gt;) = Datumsmuster | ||
| $D(<date>) = Datumsmuster | ||
| $I = Dateiindexnummer | ||
| $J = Dateiindexnummer, versteckt wenn 09 |
There was a problem hiding this comment.
still reads $J = Dateiindexnummer, versteckt wenn 09 — should be 0. Correct in the English and Chinese files.
There was a problem hiding this comment.
Fixed (versteckt wenn null)
| } | ||
|
|
||
| fmt = fmt.Replace("*", ".*", StringComparison.Ordinal); | ||
| fmt = fmt.Replace("*", ".*?", StringComparison.Ordinal); |
There was a problem hiding this comment.
.→.? and :194 \A…\z anchoring change every mask, not just ones with trailing literals. It holds because RolloverFilenameHandler.cs:39 passes a bare filename, not a path — but that invariant is undocumented and untested; a full path with any non-*-prefixed mask now fails where it previously matched.
There was a problem hiding this comment.
Kept the end anchor needed to parse an index before fixed suffix, but removed the start anchor.
This retains compatibility with full paths and non-wildcard masks.
Added a full-path regression test.
(6)
| @@ -116,15 +116,23 @@ public string BuildFileName () | |||
|
|
|||
There was a problem hiding this comment.
A saved mask *$J(.).log previously behaved as *$J(.) and matched app.log.1; it now means app.1.log. That's what the spec wants, but existing user settings change
There was a problem hiding this comment.
Should the corrected syntax remain this way?
- *$J(.) → app.log, app.log.1, app.log.2
- *$J(.).log → app.log, app.1.log, app.2.log
If so, should I migrate existing saved *$J(.).log masks to *$J(.)?
(7)
There was a problem hiding this comment.
Added settings migration for corrected MultiFile mask behavior.
To preserve compatibility, settings version 2 migrates existing saved *$J(.).log preferences to *$J(.). New version-2 settings keep both patterns distinct:
- *$J(.) for app.log.1
- *$J(.).log for app.1.log
Added tests for migrating old settings and for ensuring new settings are not changed.
Added a settings migration for the corrected MultiFile mask behavior. Older LogExpert versions accidentally ignored the trailing .log in saved *$J(.).log masks, so users effectively got app.log.1 style rollover files. The parser now correctly supports app.1.log for that mask, but this would otherwise change existing users’ saved behavior. To preserve compatibility, settings version 2 migrates existing saved *$J(.).log preferences to *$J(.). New version-2 settings keep both patterns distinct: - *$J(.) for app.log.1 - *$J(.).log for app.1.log Added tests for migrating old settings and for ensuring new settings are not changed.
| private static void MigrateToV2 (Settings settings) | ||
| { | ||
| // Before the parser fix, the trailing .log in this pattern was ignored, making it behave | ||
| // like *$J(.). Preserve that behavior for existing saved preferences. |
There was a problem hiding this comment.
rewrites a saved *$J(.).log to *$J(.). A user who already saved that pattern hoping for the new grouping, which is exactly the situation, gets silently reverted to the old behaviour. It bumps the schema version for every user and does not touch per-file persisted patterns, so the "preserve old behaviour" rationale is inconsistent.
There was a problem hiding this comment.
(1) OK, I've removed the settings migration completely:
- Schema version restored to 1.
- Removed MigrateToV2().
- Removed its tests and test-only import.
- Existing saved *$J(.).log masks now retain the corrected new meaning.
| fmt = fmt.Replace("$I", indexPattern, StringComparison.Ordinal); | ||
| fmt = fmt.Replace("$J", conditionalIndexPattern, StringComparison.Ordinal); | ||
|
|
||
| _regex = new Regex(fmt + @"\z"); |
There was a problem hiding this comment.
$I digit class went from one-or-more to zero-or-more. A plain app.log now matches *$I.log as index 0, so $I behaves like $J when the opened file has no digits.
*$J(.) no longer behaves exactly as before. The lazy wildcard plus the end anchor mean opening engine.log.1 now yields index 1. Previously it yielded index 0 and generated engine.log.1.1. This is a fix, but it is an unstated behaviour change.
The FlushEscaped method named in the PR description does not exist. The helpers are AppendEscapedSegment and AppendSegment.
There was a problem hiding this comment.
(2) Restored $I behavior:
- $I again requires one or more digits ([\d]+).
- app.log no longer matches *$I.log.
- $J retains its zero-index handling and the rotated-file parsing fix.
- The corrected behavior for opening engine.log.1 is kept; describe that explicitly in the PR rather than treating it as invisible behavior.
Note: I cannot find FlushEscaped in the current PR description.
| [Test] | ||
| public void SingleFileCtor_MultiFileTrue_LoadsIndexBeforeExtension () | ||
| { | ||
| var options = new MultiFileOptions { FormatPattern = "*$J(.).log" }; |
There was a problem hiding this comment.
The $I case is only tested from the active file, despite the PR description claiming a rotated-file $I test.
There was a problem hiding this comment.
(3) Updated the $I test to start from a rotated filename:
engine1.log → engine2.log
with *$I.log.
| { | ||
| return new LogfileReader( | ||
| _logFile, | ||
| new EncodingOptions { Encoding = Encoding.UTF8 }, |
There was a problem hiding this comment.
opens the active file only. Opening a rotated file directly never picks up lower siblings because the name-list walk only goes upward. That is pre-existing, but the spec is only met when the active file is opened.
No round-trip test for *$J(.).log.
There was a problem hiding this comment.
My understanding is that this is intended behavior: when a rotated file such as app.1.log is opened, MultiFile loads that file and higher indexes only. To load the complete chain including lower indexes, the user should open the active file (app.log). If user want to open log file from specified date only and further, i.e. app.8.log, this is a useful behaviour and in this case app will load app.8.log -> app.9.log etc.
Please confirm whether this is correct. If MultiFile should also discover lower-index siblings when a rotated file is opened directly, I can extend the handler and add coverage for that behavior.
This is a fix for #696
Fixed/MultiFile rollover patterns with an index before the extension:
*$J(.).lognow correctly expands: app.log → app.1.log → app.2.log.Updated MultiFile mask dialog legend:
<date>and<prefix>placeholders in English and German resources.*$J(.)→ app.log, app.log.1, app.log.2*$J(.).log→ app.log, app.1.log, app.2.logBefore

After

Before

After

*$J(.).log.*$J(.)coverage for app.log.1 / app.log.2.Verification:
- Full test suite: 1064 passed, 0 failed, 7 skipped.
- Resource project build: passed with 0 warnings, 0 errors.