From 56f06c3917a17ce97714132819261bb3df207285 Mon Sep 17 00:00:00 2001 From: Yuta Date: Wed, 29 Jul 2026 18:12:51 +0900 Subject: [PATCH 1/3] test: add OnKeyDown characterization tests for MorphTableView/MorphTreeView Locks current key-consumption semantics (vim navigation, gg/G sequence, global-shortcut bubble-up, non-vim/non-global base fallback) before the upcoming S1541 complexity refactor. Co-Authored-By: Claude Sonnet 5 --- .../App/Views/MorphTableViewTests.cs | 162 +++++++++++++++++ .../App/Views/MorphTreeViewTests.cs | 172 ++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 tests/Refedle.Tests/App/Views/MorphTreeViewTests.cs diff --git a/tests/Refedle.Tests/App/Views/MorphTableViewTests.cs b/tests/Refedle.Tests/App/Views/MorphTableViewTests.cs index fa99b5b..4034768 100644 --- a/tests/Refedle.Tests/App/Views/MorphTableViewTests.cs +++ b/tests/Refedle.Tests/App/Views/MorphTableViewTests.cs @@ -1,5 +1,8 @@ using AwesomeAssertions; using Refedle.App.Views; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; using Terminal.Gui.Views; namespace Refedle.Tests.App.Views; @@ -8,6 +11,17 @@ public sealed class MorphTableViewTests { private sealed class ConcreteMorphTableView : MorphTableView { + // Exposes the protected OnKeyDown for characterization testing. + public bool ProcessKey(Key key) => OnKeyDown(key); + } + + // Minimal 2-row source so navigation commands (Down/Up) are valid and get consumed. + private sealed class TwoRowTableSource : ITableSource + { + public int Rows => 2; + public int Columns => 1; + public string[] ColumnNames => ["c"]; + public object this[int row, int col] => row; } private sealed class DisposableTableSource : ITableSource, IDisposable @@ -20,6 +34,15 @@ private sealed class DisposableTableSource : ITableSource, IDisposable public void Dispose() => IsDisposed = true; } + private static IApplication CreateTestApp() + { + var app = Application.Create(); + app.Init(DriverRegistry.Names.ANSI); + Assert.NotNull(app.Driver); + app.Driver.SetScreenSize(80, 25); + return app; + } + private sealed class NonDisposableTableSource : ITableSource { public int Rows => 0; @@ -57,4 +80,143 @@ public void Dispose_DoesNotThrow_WhenTableIsNotIDisposable() // Assert act.Should().NotThrow(); } + + // ----------------------------------------------------------------------- + // OnKeyDown characterization tests + // These lock the current return-value semantics (consumed=true / + // bubbles-up=false) so the upcoming complexity refactor — which extracts a + // pure MapCommand helper and splits dispatch — cannot silently change + // which keys are consumed. No production code is modified here. + // ----------------------------------------------------------------------- + + [Fact] + public void OnKeyDown_WhenTableIsNull_DelegatesToBaseWithoutThrowing() + { + // Arrange — Table is null, so OnKeyDown short-circuits to the base view. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTableView(); + + // Act + var act = () => view.ProcessKey(new Key(KeyCode.J)); + + // Assert — the vim path is skipped; base.OnKeyDown runs without throwing. + act.Should().NotThrow(); + } + + [Theory] + [InlineData(KeyCode.H)] + [InlineData(KeyCode.J)] + [InlineData(KeyCode.K)] + [InlineData(KeyCode.L)] + [InlineData(KeyCode.D)] + [InlineData(KeyCode.U)] + public void OnKeyDown_VimNavigationKey_IsConsumedAndReturnsTrue(KeyCode keyCode) + { + // Arrange + using var app = CreateTestApp(); + using var view = new ConcreteMorphTableView + { + Table = new TwoRowTableSource(), + }; + + // Act + var result = view.ProcessKey(new Key(keyCode)); + + // Assert — hjkl/du are mapped to commands and consumed. + result.Should().BeTrue(); + } + + [Fact] + public void OnKeyDown_FirstGOfSequence_IsConsumedAndReturnsTrue() + { + // Arrange — first 'g' enters the pending state (PendingGSequence). + using var app = CreateTestApp(); + using var view = new ConcreteMorphTableView + { + Table = new TwoRowTableSource(), + }; + + // Act + var result = view.ProcessKey(new Key(KeyCode.G)); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void OnKeyDown_GGSequence_GoesToFirstAndReturnsTrue() + { + // Arrange — 'gg' within the timeout resolves to GoToFirst. The view owns its own + // translator with a 1000 ms timeout, so two synchronous presses stay well within it. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTableView + { + Table = new TwoRowTableSource(), + }; + + // Act + var first = view.ProcessKey(new Key(KeyCode.G)); + var second = view.ProcessKey(new Key(KeyCode.G)); + + // Assert — both presses are consumed; the pair navigates to the first row. + first.Should().BeTrue(); + second.Should().BeTrue(); + } + + [Fact] + public void OnKeyDown_ShiftG_GoesToEndAndReturnsTrue() + { + // Arrange — Shift+G resolves to GoToEnd. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTableView + { + Table = new TwoRowTableSource(), + }; + + // Act + var result = view.ProcessKey(new Key(KeyCode.G | KeyCode.ShiftMask)); + + // Assert + result.Should().BeTrue(); + } + + [Theory] + [InlineData(KeyCode.O)] + [InlineData(KeyCode.Q)] + [InlineData(KeyCode.T)] + public void OnKeyDown_GlobalShortcut_BubblesUpAndReturnsFalse(KeyCode keyCode) + { + // Arrange — global shortcuts must NOT be consumed so they reach AppKeyHandler. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTableView + { + Table = new TwoRowTableSource(), + }; + + // Act + var result = view.ProcessKey(new Key(keyCode)); + + // Assert + result.Should().BeFalse(); + } + + [Theory] + [InlineData(KeyCode.Enter)] + [InlineData(KeyCode.Esc)] + public void OnKeyDown_NonVimNonGlobalKey_DelegatesToBaseWithoutThrowing(KeyCode keyCode) + { + // Arrange — neither a vim key nor a global shortcut, so it falls through to base.OnKeyDown. + // The return value depends on base TableView state, so only exception-safety is locked. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTableView + { + Table = new TwoRowTableSource(), + }; + + // Act + var act = () => view.ProcessKey(new Key(keyCode)); + + // Assert + act.Should().NotThrow(); + } } diff --git a/tests/Refedle.Tests/App/Views/MorphTreeViewTests.cs b/tests/Refedle.Tests/App/Views/MorphTreeViewTests.cs new file mode 100644 index 0000000..1d8be68 --- /dev/null +++ b/tests/Refedle.Tests/App/Views/MorphTreeViewTests.cs @@ -0,0 +1,172 @@ +using AwesomeAssertions; +using Refedle.App.Views; +using Terminal.Gui.App; +using Terminal.Gui.Drivers; +using Terminal.Gui.Input; +using Terminal.Gui.Views; + +namespace Refedle.Tests.App.Views; + +public sealed class MorphTreeViewTests +{ + private sealed class ConcreteMorphTreeView : MorphTreeView + { + public ConcreteMorphTreeView(Action onTableModeToggle, Action onSelectionChanged) + : base(onTableModeToggle, onSelectionChanged) + { + } + + // Exposes the protected OnKeyDown for characterization testing. + public bool ProcessKey(Key key) => OnKeyDown(key); + } + + private static IApplication CreateTestApp() + { + var app = Application.Create(); + app.Init(DriverRegistry.Names.ANSI); + Assert.NotNull(app.Driver); + app.Driver.SetScreenSize(80, 25); + return app; + } + + // ----------------------------------------------------------------------- + // OnKeyDown characterization tests + // These lock the current return-value semantics (consumed=true / + // bubbles-up=false) and the T-key table-mode toggle side effect, so the + // upcoming complexity refactor — which groups the four selection-offset + // moves into a single TrySelectionMove helper — cannot silently change + // which keys are consumed or whether the toggle fires. No production code + // is modified here. + // ----------------------------------------------------------------------- + + [Fact] + public void OnKeyDown_TKey_InvokesTableModeToggleAndReturnsTrue() + { + // Arrange — T switches to table mode via the injected callback. + using var app = CreateTestApp(); + var toggled = false; + using var view = new ConcreteMorphTreeView(() => toggled = true, _ => { }); + + // Act + var result = view.ProcessKey(new Key(KeyCode.T)); + + // Assert — the toggle fires and the key is consumed. + result.Should().BeTrue(); + toggled.Should().BeTrue(); + } + + [Theory] + [InlineData(KeyCode.J)] + [InlineData(KeyCode.K)] + [InlineData(KeyCode.D)] + [InlineData(KeyCode.U)] + public void OnKeyDown_VimSelectionKey_IsConsumedAndReturnsTrue(KeyCode keyCode) + { + // Arrange — j/k move by one row, d/u move by one page; all go through ConsumeAction. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTreeView(() => { }, _ => { }); + + // Act + var result = view.ProcessKey(new Key(keyCode)); + + // Assert + result.Should().BeTrue(); + } + + [Theory] + [InlineData(KeyCode.H)] + [InlineData(KeyCode.L)] + public void OnKeyDown_HorizontalVimKey_DelegatesToBaseWithoutThrowing(KeyCode keyCode) + { + // Arrange — h/l are forwarded to the base TreeView as CursorLeft/CursorRight. + // Their return value depends on base TreeView state, so only exception-safety is locked. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTreeView(() => { }, _ => { }); + + // Act + var act = () => view.ProcessKey(new Key(keyCode)); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void OnKeyDown_FirstGOfSequence_IsConsumedAndReturnsTrue() + { + // Arrange — first 'g' enters the pending state (PendingGSequence). + using var app = CreateTestApp(); + using var view = new ConcreteMorphTreeView(() => { }, _ => { }); + + // Act + var result = view.ProcessKey(new Key(KeyCode.G)); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void OnKeyDown_GGSequence_GoesToFirstAndReturnsTrue() + { + // Arrange — 'gg' within the timeout resolves to GoToFirst. The view owns its own + // translator with a 1000 ms timeout, so two synchronous presses stay well within it. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTreeView(() => { }, _ => { }); + + // Act + var first = view.ProcessKey(new Key(KeyCode.G)); + var second = view.ProcessKey(new Key(KeyCode.G)); + + // Assert — both presses are consumed; the pair navigates to the first node. + first.Should().BeTrue(); + second.Should().BeTrue(); + } + + [Fact] + public void OnKeyDown_ShiftG_GoesToEndAndReturnsTrue() + { + // Arrange — Shift+G resolves to GoToEnd. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTreeView(() => { }, _ => { }); + + // Act + var result = view.ProcessKey(new Key(KeyCode.G | KeyCode.ShiftMask)); + + // Assert + result.Should().BeTrue(); + } + + [Theory] + [InlineData(KeyCode.O)] + [InlineData(KeyCode.Q)] + [InlineData(KeyCode.X)] + public void OnKeyDown_GlobalShortcut_BubblesUpAndReturnsFalse(KeyCode keyCode) + { + // Arrange — global shortcuts must NOT be consumed so they reach AppKeyHandler. + // (T is excluded here because it is intercepted earlier as the table-mode toggle.) + using var app = CreateTestApp(); + using var view = new ConcreteMorphTreeView(() => { }, _ => { }); + + // Act + var result = view.ProcessKey(new Key(keyCode)); + + // Assert + result.Should().BeFalse(); + } + + [Theory] + [InlineData(KeyCode.Enter)] + [InlineData(KeyCode.Esc)] + public void OnKeyDown_NonVimNonGlobalKey_DelegatesToBaseWithoutThrowing(KeyCode keyCode) + { + // Arrange — neither a vim key nor a global shortcut, so it falls through to base.OnKeyDown. + // The return value depends on base TreeView state, so only exception-safety is locked. + using var app = CreateTestApp(); + using var view = new ConcreteMorphTreeView(() => { }, _ => { }); + + // Act + var act = () => view.ProcessKey(new Key(keyCode)); + + // Assert + act.Should().NotThrow(); + } +} From 8a290737a62eb580ec5681f8db114fd2e35a3afe Mon Sep 17 00:00:00 2001 From: Yuta Date: Wed, 29 Jul 2026 20:54:31 +0900 Subject: [PATCH 2/3] test: add HandleFileSelectedAsync characterization tests for Csv/JsonArray/unsupported-extension branches Locks the Csv, JsonArray, and detection-failure branches before the upcoming S1541 complexity refactor of FileDialogHandler. Co-Authored-By: Claude Sonnet 5 --- .../App/FileDialogHandlerTests.cs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/tests/Refedle.Tests/App/FileDialogHandlerTests.cs b/tests/Refedle.Tests/App/FileDialogHandlerTests.cs index 03601e4..a82f46f 100644 --- a/tests/Refedle.Tests/App/FileDialogHandlerTests.cs +++ b/tests/Refedle.Tests/App/FileDialogHandlerTests.cs @@ -16,6 +16,9 @@ public sealed class FileDialogHandlerTests : IDisposable { private readonly string _jsonLinesFile; private readonly string _jsonObjectFile; + private readonly string _csvFile; + private readonly string _jsonArrayFile; + private readonly string _unsupportedFile; public FileDialogHandlerTests() { @@ -24,6 +27,15 @@ public FileDialogHandlerTests() _jsonObjectFile = Path.ChangeExtension(Path.GetTempFileName(), ".json"); File.WriteAllText(_jsonObjectFile, "{\"name\":\"test\",\"count\":42}"); + + _csvFile = Path.ChangeExtension(Path.GetTempFileName(), ".csv"); + File.WriteAllText(_csvFile, "header\ndata"); + + _jsonArrayFile = Path.ChangeExtension(Path.GetTempFileName(), ".json"); + File.WriteAllText(_jsonArrayFile, "[{\"id\":1}]"); + + _unsupportedFile = Path.ChangeExtension(Path.GetTempFileName(), ".txt"); + File.WriteAllText(_unsupportedFile, "not a data file"); } public void Dispose() @@ -37,6 +49,21 @@ public void Dispose() { File.Delete(_jsonObjectFile); } + + if (File.Exists(_csvFile)) + { + File.Delete(_csvFile); + } + + if (File.Exists(_jsonArrayFile)) + { + File.Delete(_jsonArrayFile); + } + + if (File.Exists(_unsupportedFile)) + { + File.Delete(_unsupportedFile); + } } private static IApplication CreateTestApp() @@ -273,4 +300,82 @@ public async Task HandleFileSelectedAsync_NonJsonObjectFile_ResetsJsonObjectEntr state.JsonObjectEntries.Should().BeNull(); capturedIndexer.Should().NotBeNull(); } + + [Fact] + public async Task HandleFileSelectedAsync_CsvFile_SwitchesToCsvTable() + { + // Arrange + using var app = CreateTestApp(); + using var state = new AppState(); + using var window = new Window(); + var modeController = new ModeController(state); + using var viewManager = new ViewManager(window, state, modeController, action => action()); + + var handler = new FileDialogHandler(app, state, viewManager, _ => { }, () => { }); + + // Act + app.Begin(window); + await handler.HandleFileSelectedAsync(_csvFile); + app.StopAfterFirstIteration = true; + app.Run(window); + + // Assert + state.CurrentMode.Should().Be(ViewMode.CsvTable); + viewManager.GetCurrentView().Should().BeOfType(); + } + + [Fact] + public async Task HandleFileSelectedAsync_JsonArrayFile_SwitchesToTreeViewAfterFirstCheckpoint() + { + // Arrange + using var app = CreateTestApp(); + using var state = new AppState(); + using var window = new Window(); + var modeController = new ModeController(state); + using var viewManager = new ViewManager(window, state, modeController, action => action()); + + IRowIndexer? capturedIndexer = null; + var handler = new FileDialogHandler(app, state, viewManager, indexer => + { + capturedIndexer = indexer; + // Simulate indexing start + Task.Run(() => indexer.BuildIndex()); + }, () => { }); + + // Act + app.Begin(window); + await handler.HandleFileSelectedAsync(_jsonArrayFile); + app.StopAfterFirstIteration = true; + app.Run(window); + + // Assert + state.CurrentMode.Should().Be(ViewMode.JsonArrayTree); + viewManager.GetCurrentView().Should().BeOfType(); + Assert.NotNull(capturedIndexer); + capturedIndexer.TotalRows.Should().BeGreaterThan(0); + } + + [Fact] + public async Task HandleFileSelectedAsync_UnsupportedExtension_ShowsErrorAndSwitchesToPlaceholderView() + { + // Arrange + using var app = CreateTestApp(); + using var state = new AppState(); + using var window = new Window(); + var modeController = new ModeController(state); + using var viewManager = new ViewManager(window, state, modeController, action => action()); + + var handler = new FileDialogHandler(app, state, viewManager, _ => { }, () => { }); + + // Act + app.Begin(window); + await handler.HandleFileSelectedAsync(_unsupportedFile); + app.StopAfterFirstIteration = true; + app.Run(window); + + // Assert + state.CurrentMode.Should().Be(ViewMode.PlaceholderView); + viewManager.GetCurrentView().Should().BeOfType() + .Which.Text.Should().Contain("Unsupported file format: .txt"); + } } From 4e29bffe8ca5c3ed730f289e9690c578b14d69ef Mon Sep 17 00:00:00 2001 From: Yuta Date: Wed, 29 Jul 2026 21:56:24 +0900 Subject: [PATCH 3/3] refactor: resolve SonarAnalyzer S3776/S1541 complexity warnings Split flagged high-complexity methods across 20 files into smaller helper methods; removed the corresponding grandfathered suppression blocks from .editorconfig. No behavioral changes. Co-Authored-By: Claude Sonnet 5 --- .editorconfig | 82 ------- src/App/AppKeyHandler.cs | 142 ++++++----- src/App/Cli/ArgumentParser.cs | 87 +++---- src/App/FileDialogHandler.cs | 248 +++++++++++--------- src/App/ViewManager.cs | 82 ++++--- src/App/Views/LazyTransformer.cs | 239 ++++++++++--------- src/App/Views/MorphTableView.cs | 61 +++-- src/App/Views/MorphTreeView.cs | 38 ++- src/App/Views/VimKeyTranslator.cs | 7 +- src/Engine/ActionApplier.cs | 207 +++++++++------- src/Engine/Filtering/FilterEvaluator.cs | 61 ++--- src/Engine/IO/ColumnTypeResolver.cs | 61 +---- src/Engine/IO/Csv/DataRowReader.cs | 111 ++++----- src/Engine/IO/DrillDown/KeyPathTraverser.cs | 87 ++++--- src/Engine/IO/JsonArray/ElementReader.cs | 170 ++++++++------ src/Engine/IO/JsonArray/RowIndexer.cs | 98 ++++---- src/Engine/IO/JsonLines/RowReader.cs | 31 ++- src/Engine/IO/JsonLines/SchemaScanner.cs | 127 ++++++---- src/Engine/IO/JsonLines/TypeInferrer.cs | 76 +++--- src/Engine/IO/JsonObject/TopLevelScanner.cs | 11 + src/Engine/Recipes/RecipeYamlParser.cs | 167 +++++++------ 21 files changed, 1170 insertions(+), 1023 deletions(-) diff --git a/.editorconfig b/.editorconfig index e93454d..bbadf12 100644 --- a/.editorconfig +++ b/.editorconfig @@ -339,85 +339,3 @@ dotnet_diagnostic.CA1849.severity = none # Test code runs on the test context and doesn't need ConfigureAwait. # xUnit tests should not use ConfigureAwait(false) (xUnit1030). dotnet_diagnostic.CA2007.severity = none - -# SonarAnalyzer.CSharp: S3776/S1541 (Cognitive/Cyclomatic Complexity) pre-existing violations, -# grandfathered per-file pending a follow-up refactor task. -[src/Engine/Filtering/FilterEvaluator.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/IO/ColumnTypeResolver.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/ActionApplier.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/IO/Csv/DataRowReader.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/Recipes/RecipeYamlParser.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/IO/JsonObject/TopLevelScanner.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/IO/DrillDown/KeyPathTraverser.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/IO/JsonArray/RowIndexer.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/IO/JsonArray/ElementReader.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/IO/JsonLines/TypeInferrer.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/IO/JsonLines/SchemaScanner.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/Engine/IO/JsonLines/RowReader.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/App/Cli/ArgumentParser.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/App/AppKeyHandler.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/App/Views/VimKeyTranslator.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/App/FileDialogHandler.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/App/ViewManager.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/App/Views/MorphTableView.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/App/Views/MorphTreeView.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none - -[src/App/Views/LazyTransformer.cs] -dotnet_diagnostic.S3776.severity = none -dotnet_diagnostic.S1541.severity = none diff --git a/src/App/AppKeyHandler.cs b/src/App/AppKeyHandler.cs index e0c3761..ee0370b 100644 --- a/src/App/AppKeyHandler.cs +++ b/src/App/AppKeyHandler.cs @@ -164,62 +164,72 @@ private bool HandleViewToggle() return false; } - [SuppressMessage( - "Reliability", - "CA2000:Dispose objects before losing scope", - Justification = "The dialog is managed by Terminal.Gui's IApplication.Run() and will be disposed automatically." - )] internal bool HandleActionMenu() { var currentView = _viewManager.GetCurrentView(); if (currentView is MorphTableView mt) { - if (mt.Table is null || mt.GetRawColumnName is null - || mt.OnMorphAction is null || mt.Value is null) - { - return false; - } + return HandleActionMenuForTable(mt); + } - var format = FormatDetector.Detect(_state.CurrentFilePath); - if (format.IsFailure) - { - _app.Invoke(() => _viewManager.ShowError(format.Error)); - return false; - } + if (currentView is MorphTreeView tv) + { + return HandleActionMenuForTree(tv); + } - var handler = new ColumnActionHandler( - _app, mt.Table, mt.Value.SelectedCell.X, - mt.GetRawColumnName, mt.OnMorphAction, format.Value, mt.IsRowIndexComplete); + return false; + } - var dialog = new ActionMenuDialog(ColumnActionHandler.GetAvailableActions(), handler.ExecuteAction); - _app.Run(dialog); - return true; + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "The dialog is managed by Terminal.Gui's IApplication.Run() and will be disposed automatically." + )] + private bool HandleActionMenuForTable(MorphTableView mt) + { + if (mt.Table is null || mt.GetRawColumnName is null + || mt.OnMorphAction is null || mt.Value is null) + { + return false; } - if (currentView is MorphTreeView tv) + var format = FormatDetector.Detect(_state.CurrentFilePath); + if (format.IsFailure) { - if (tv.SelectedObject is not ITreeNode selectedNode) - { - return false; - } + _app.Invoke(() => _viewManager.ShowError(format.Error)); + return false; + } - var treeFormat = FormatDetector.Detect(_state.CurrentFilePath); - if (treeFormat.IsFailure) - { - _app.Invoke(() => _viewManager.ShowError(treeFormat.Error)); - return false; - } + var handler = new ColumnActionHandler( + _app, mt.Table, mt.Value.SelectedCell.X, + mt.GetRawColumnName, mt.OnMorphAction, format.Value, mt.IsRowIndexComplete); - if (treeFormat.Value == DataFormat.JsonObject) - { - return HandleSingleDrillDown(selectedNode, treeFormat.Value); - } + var dialog = new ActionMenuDialog(ColumnActionHandler.GetAvailableActions(), handler.ExecuteAction); + _app.Run(dialog); + return true; + } - return HandleFullAggregationDrillDown(selectedNode, treeFormat.Value); + private bool HandleActionMenuForTree(MorphTreeView tv) + { + if (tv.SelectedObject is not ITreeNode selectedNode) + { + return false; } - return false; + var treeFormat = FormatDetector.Detect(_state.CurrentFilePath); + if (treeFormat.IsFailure) + { + _app.Invoke(() => _viewManager.ShowError(treeFormat.Error)); + return false; + } + + if (treeFormat.Value == DataFormat.JsonObject) + { + return HandleSingleDrillDown(selectedNode, treeFormat.Value); + } + + return HandleFullAggregationDrillDown(selectedNode, treeFormat.Value); } /// @@ -348,7 +358,37 @@ private void OnGlobalKeyDown(object? sender, Key key) return; } - // Skip global key handling when a text input view is focused + if (IsTextFieldFocused()) + { + return; + } + + // Shortcuts like o, s, q, t, x, Backspace should not have Ctrl or Alt modifiers. + if ((key.KeyCode & (KeyCode.CtrlMask | KeyCode.AltMask)) != 0) + { + return; + } + + key.Handled = DispatchShortcut(key.KeyCode & ~KeyCode.ShiftMask); + } + + private bool DispatchShortcut(KeyCode baseKey) => baseKey switch + { + KeyCode.O => HandleOpen(), + KeyCode.S => HandleSave(), + KeyCode.Q => HandleQuit(), + KeyCode.T => HandleViewToggle(), + KeyCode.X => HandleActionMenu(), + KeyCode.C => HandleClearActions(), + KeyCode.Backspace => HandleDrillDownBack(), + (KeyCode)'?' => HandleHelp(), + _ => false, + }; + + // Walks up the SuperView chain since focus may be on a child of the TextField + // (e.g. its internal cursor/selection handling), not the TextField itself. + private bool IsTextFieldFocused() + { var focused = _app.Navigation?.GetFocused() ?? _app.TopRunnableView?.MostFocused; var current = focused; while (current is not null) @@ -356,31 +396,13 @@ private void OnGlobalKeyDown(object? sender, Key key) var type = current.GetType(); if (current is TextField || type.Name == "TextField" || type.FullName == "Terminal.Gui.Views.TextField") { - return; + return true; } current = current.SuperView; } - // Shortcuts like o, s, q, t, x, Backspace should not have Ctrl or Alt modifiers. - if ((key.KeyCode & (KeyCode.CtrlMask | KeyCode.AltMask)) != 0) - { - return; - } - - var baseKey = key.KeyCode & ~KeyCode.ShiftMask; - key.Handled = baseKey switch - { - KeyCode.O => HandleOpen(), - KeyCode.S => HandleSave(), - KeyCode.Q => HandleQuit(), - KeyCode.T => HandleViewToggle(), - KeyCode.X => HandleActionMenu(), - KeyCode.C => HandleClearActions(), - KeyCode.Backspace => HandleDrillDownBack(), - (KeyCode)'?' => HandleHelp(), - _ => false, - }; + return false; } public void Dispose() diff --git a/src/App/Cli/ArgumentParser.cs b/src/App/Cli/ArgumentParser.cs index d430422..cf202cf 100644 --- a/src/App/Cli/ArgumentParser.cs +++ b/src/App/Cli/ArgumentParser.cs @@ -35,56 +35,61 @@ public static Result Parse(IReadOnlyList args) while (i < args.Count) { - if (!args[i].StartsWith("--", StringComparison.Ordinal)) + var stepResult = ParseNextToken(args, i, result); + if (stepResult.IsFailure) { - return Results.Failure($"Invalid flag: '{args[i]}'"); + return Results.Failure(stepResult.Error); } - if (args[i].Equals(CliFlag, StringComparison.Ordinal)) - { - i += 1; - continue; - } + i = stepResult.Value; + } - if (args[i].Equals(InputFlag, StringComparison.Ordinal)) - { - if (i + 1 >= args.Count || args[i + 1].StartsWith("--", StringComparison.Ordinal)) - { - return Results.Failure($"Missing value for {InputFlag}"); - } - - result.InputFile = args[i + 1]; - i += 2; - continue; - } + return BuildArguments(result); + } - if (args[i].Equals(RecipeFlag, StringComparison.Ordinal)) - { - if (i + 1 >= args.Count || args[i + 1].StartsWith("--", StringComparison.Ordinal)) - { - return Results.Failure($"Missing value for {RecipeFlag}"); - } - - result.RecipeFile = args[i + 1]; - i += 2; - continue; - } + private static Result ParseNextToken(IReadOnlyList args, int i, ArgumentsParseResult result) + { + if (!args[i].StartsWith("--", StringComparison.Ordinal)) + { + return Results.Failure($"Invalid flag: '{args[i]}'"); + } - if (args[i].Equals(OutputFlag, StringComparison.Ordinal)) - { - if (i + 1 >= args.Count || args[i + 1].StartsWith("--", StringComparison.Ordinal)) - { - return Results.Failure($"Missing value for {OutputFlag}"); - } - - result.OutputFile = args[i + 1]; - i += 2; - continue; - } + if (args[i].Equals(CliFlag, StringComparison.Ordinal)) + { + return Results.Success(i + 1); + } - return Results.Failure($"Unknown flag: {args[i]}"); + if (args[i].Equals(InputFlag, StringComparison.Ordinal)) + { + return ConsumeValueFlag(args, i, InputFlag, value => result.InputFile = value); } + if (args[i].Equals(RecipeFlag, StringComparison.Ordinal)) + { + return ConsumeValueFlag(args, i, RecipeFlag, value => result.RecipeFile = value); + } + + if (args[i].Equals(OutputFlag, StringComparison.Ordinal)) + { + return ConsumeValueFlag(args, i, OutputFlag, value => result.OutputFile = value); + } + + return Results.Failure($"Unknown flag: {args[i]}"); + } + + private static Result ConsumeValueFlag(IReadOnlyList args, int i, string flag, Action assign) + { + if (i + 1 >= args.Count || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + return Results.Failure($"Missing value for {flag}"); + } + + assign(args[i + 1]); + return Results.Success(i + 2); + } + + private static Result BuildArguments(ArgumentsParseResult result) + { if (string.IsNullOrWhiteSpace(result.InputFile)) { return Results.Failure($"Missing required flag: {InputFlag}"); diff --git a/src/App/FileDialogHandler.cs b/src/App/FileDialogHandler.cs index 882a2e8..5c2bc7b 100644 --- a/src/App/FileDialogHandler.cs +++ b/src/App/FileDialogHandler.cs @@ -67,32 +67,7 @@ internal async Task HandleFileSelectedAsync(string path) // No IRowIndexer is needed — keys are not rows. if (format == DataFormat.JsonObject) { - _stopIndexing(); - _state.RowIndexer = null; - _state.Schema = null; - _state.OnSchemaRefined = null; - - var ct = _state.Cts.Token; - try - { - var entries = await Task.Run( - () => Engine.IO.JsonObject.TopLevelScanner.Scan(path, ct), ct); - _app.Invoke(() => - { - _state.CurrentMode = ViewMode.JsonObjectTree; - _state.JsonObjectEntries = entries; - _viewManager.SwitchToJsonObjectTree(entries); - }); - } - catch (OperationCanceledException) { /* file reloaded before scan completed */ } -#pragma warning disable CA1031 // UI top-level handler - catch (Exception ex) -#pragma warning restore CA1031 - { - _app.Invoke(() => - _viewManager.ShowError($"Error loading JSON Object: {ex.Message}")); - } - + await LoadJsonObjectAsync(path); return; } @@ -102,118 +77,159 @@ internal async Task HandleFileSelectedAsync(string path) // SwitchToView(format) if (format == DataFormat.Csv) { - var schemaScanner = new IncrementalSchemaScanner(path); - try + await LoadCsvAsync(path, indexer); + return; + } + + if (format == DataFormat.JsonLines) + { + await LoadJsonLinesAsync(indexer); + return; + } + + if (format == DataFormat.JsonArray) + { + await LoadJsonArrayAsync(indexer); + return; + } + + _onIndexerStart(indexer); + } + + private async Task LoadJsonObjectAsync(string path) + { + _stopIndexing(); + _state.RowIndexer = null; + _state.Schema = null; + _state.OnSchemaRefined = null; + + var ct = _state.Cts.Token; + try + { + var entries = await Task.Run( + () => Engine.IO.JsonObject.TopLevelScanner.Scan(path, ct), ct); + _app.Invoke(() => { - var schema = await schemaScanner.InitialScanAsync(); - _app.Invoke(() => - { - if (schema.Columns.Count == 0) - { - _viewManager.ShowError("File contains no data"); - return; - } - - _state.Schema = schema; - _state.RowIndexer = indexer; - _state.CurrentMode = ViewMode.CsvTable; - - _viewManager.SwitchToCsvTable(indexer, schema); - - _ = schemaScanner - .StartBackgroundScanAsync(schema, _state.Cts.Token) - .ContinueWith( - t => - { - if (!t.IsCompletedSuccessfully) - { - return; - } - - _app.Invoke(() => - { - _state.Schema = t.Result; - _state.OnSchemaRefined?.Invoke(t.Result); - }); - }, - TaskScheduler.Default - ); - - _onIndexerStart(indexer); - }); - return; - } + _state.CurrentMode = ViewMode.JsonObjectTree; + _state.JsonObjectEntries = entries; + _viewManager.SwitchToJsonObjectTree(entries); + }); + } + catch (OperationCanceledException) { /* file reloaded before scan completed */ } #pragma warning disable CA1031 // UI top-level handler - catch (Exception ex) + catch (Exception ex) #pragma warning restore CA1031 - { - _app.Invoke(() => _viewManager.ShowError($"Error scanning CSV: {ex.Message}")); - return; - } + { + _app.Invoke(() => + _viewManager.ShowError($"Error loading JSON Object: {ex.Message}")); } + } - if (format == DataFormat.JsonLines) + private async Task LoadCsvAsync(string path, IRowIndexer indexer) + { + var schemaScanner = new IncrementalSchemaScanner(path); + try { - try + var schema = await schemaScanner.InitialScanAsync(); + _app.Invoke(() => { + if (schema.Columns.Count == 0) + { + _viewManager.ShowError("File contains no data"); + return; + } + + _state.Schema = schema; _state.RowIndexer = indexer; - _state.Schema = null; - _state.OnSchemaRefined = null; + _state.CurrentMode = ViewMode.CsvTable; - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - indexer.FirstCheckpointReached += () => tcs.TrySetResult(); + _viewManager.SwitchToCsvTable(indexer, schema); - _onIndexerStart(indexer); - await tcs.Task; + _ = schemaScanner + .StartBackgroundScanAsync(schema, _state.Cts.Token) + .ContinueWith( + t => + { + if (!t.IsCompletedSuccessfully) + { + return; + } - _app.Invoke(() => - { - _state.CurrentMode = ViewMode.JsonLinesTree; - _viewManager.SwitchToJsonLinesTree(indexer); - }); - return; - } + _app.Invoke(() => + { + _state.Schema = t.Result; + _state.OnSchemaRefined?.Invoke(t.Result); + }); + }, + TaskScheduler.Default + ); + + _onIndexerStart(indexer); + }); + } #pragma warning disable CA1031 // UI top-level handler - catch (Exception ex) + catch (Exception ex) #pragma warning restore CA1031 - { - _app.Invoke(() => _viewManager.ShowError($"Error loading JSON Lines: {ex.Message}")); - return; - } + { + _app.Invoke(() => _viewManager.ShowError($"Error scanning CSV: {ex.Message}")); } + } - if (format == DataFormat.JsonArray) + private async Task LoadJsonLinesAsync(IRowIndexer indexer) + { + try { - try - { - _state.RowIndexer = indexer; - _state.Schema = null; - _state.OnSchemaRefined = null; + _state.RowIndexer = indexer; + _state.Schema = null; + _state.OnSchemaRefined = null; - var tcs = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - indexer.FirstCheckpointReached += () => tcs.TrySetResult(); + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + indexer.FirstCheckpointReached += () => tcs.TrySetResult(); - _onIndexerStart(indexer); - await tcs.Task; + _onIndexerStart(indexer); + await tcs.Task; - _app.Invoke(() => - { - _state.CurrentMode = ViewMode.JsonArrayTree; - _viewManager.SwitchToJsonArrayTree(indexer); - }); - return; - } + _app.Invoke(() => + { + _state.CurrentMode = ViewMode.JsonLinesTree; + _viewManager.SwitchToJsonLinesTree(indexer); + }); + } #pragma warning disable CA1031 // UI top-level handler - catch (Exception ex) + catch (Exception ex) #pragma warning restore CA1031 - { - _app.Invoke(() => _viewManager.ShowError($"Error loading JSON Array: {ex.Message}")); - return; - } + { + _app.Invoke(() => _viewManager.ShowError($"Error loading JSON Lines: {ex.Message}")); } + } - _onIndexerStart(indexer); + private async Task LoadJsonArrayAsync(IRowIndexer indexer) + { + try + { + _state.RowIndexer = indexer; + _state.Schema = null; + _state.OnSchemaRefined = null; + + var tcs = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + indexer.FirstCheckpointReached += () => tcs.TrySetResult(); + + _onIndexerStart(indexer); + await tcs.Task; + + _app.Invoke(() => + { + _state.CurrentMode = ViewMode.JsonArrayTree; + _viewManager.SwitchToJsonArrayTree(indexer); + }); + } +#pragma warning disable CA1031 // UI top-level handler + catch (Exception ex) +#pragma warning restore CA1031 + { + _app.Invoke(() => _viewManager.ShowError($"Error loading JSON Array: {ex.Message}")); + } } } diff --git a/src/App/ViewManager.cs b/src/App/ViewManager.cs index c809834..e30b07e 100644 --- a/src/App/ViewManager.cs +++ b/src/App/ViewManager.cs @@ -85,54 +85,70 @@ internal void RefreshStatusBarHints() if (!string.IsNullOrWhiteSpace(_state.CurrentFilePath)) { - var format = FormatDetector.Detect(_state.CurrentFilePath); - if (format.IsSuccess - && format.Value is DataFormat.JsonLines or DataFormat.JsonArray - && _state.CurrentMode != ViewMode.FocusedTable) - { - hints.Add("t:Tree/Table"); - } + AddContextualHints(hints); + } - var currentView = GetCurrentView(); - if ((currentView is MorphTableView && _state.CurrentMode != ViewMode.FocusedTable) - || currentView is MorphTreeView) - { - hints.Add("x:Menu"); - } + hints.Add("?:Help"); - if (_state.ActionStack.Count > 0) - { - hints.Add("c:Clear"); - } + PopulateShortcuts(statusBar, hints); + AddItemCountLabel(statusBar); + } - if (_state.CurrentMode == ViewMode.FocusedTable) - { - hints.Add("bs:Back"); - } + private void AddContextualHints(List hints) + { + var format = FormatDetector.Detect(_state.CurrentFilePath); + if (format.IsSuccess + && format.Value is DataFormat.JsonLines or DataFormat.JsonArray + && _state.CurrentMode != ViewMode.FocusedTable) + { + hints.Add("t:Tree/Table"); } - hints.Add("?:Help"); + var currentView = GetCurrentView(); + if ((currentView is MorphTableView && _state.CurrentMode != ViewMode.FocusedTable) + || currentView is MorphTreeView) + { + hints.Add("x:Menu"); + } + if (_state.ActionStack.Count > 0) + { + hints.Add("c:Clear"); + } + + if (_state.CurrentMode == ViewMode.FocusedTable) + { + hints.Add("bs:Back"); + } + } + + private static void PopulateShortcuts(StatusBar statusBar, List hints) + { // Populate shortcuts with Key.Empty to suppress key indicator var shortcuts = hints.Select(hint => new Shortcut { Key = Key.Empty, HelpText = hint }).ToList(); foreach (var shortcut in shortcuts) { statusBar.Add(shortcut); } + } - if (_state.RowIndexer is { IsIndexingCompleted: true }) + private void AddItemCountLabel(StatusBar statusBar) + { + if (_state.RowIndexer is not { IsIndexingCompleted: true }) { - _itemCountLabel = new Label - { - Text = $"{_state.RowIndexer.TotalRows} items", - // AnchorEnd places the right edge at the container boundary; subtract 1 to keep a margin - X = Pos.AnchorEnd() - 1, - // Place on the same row as the StatusBar (bottom line of the container) - Y = Pos.AnchorEnd(1), - SchemeName = statusBar.SchemeName, - }; - _container.Add(_itemCountLabel); + return; } + + _itemCountLabel = new Label + { + Text = $"{_state.RowIndexer.TotalRows} items", + // AnchorEnd places the right edge at the container boundary; subtract 1 to keep a margin + X = Pos.AnchorEnd() - 1, + // Place on the same row as the StatusBar (bottom line of the container) + Y = Pos.AnchorEnd(1), + SchemeName = statusBar.SchemeName, + }; + _container.Add(_itemCountLabel); } /// diff --git a/src/App/Views/LazyTransformer.cs b/src/App/Views/LazyTransformer.cs index d280b60..91570ec 100644 --- a/src/App/Views/LazyTransformer.cs +++ b/src/App/Views/LazyTransformer.cs @@ -163,79 +163,7 @@ IReadOnlyList filterSpecs foreach (var action in actions) { - if (action is RenameColumnAction rename) - { - if (!nameToIndex.TryGetValue(rename.OldName, out var renameIdx)) - { - continue; - } - - working[renameIdx] = working[renameIdx] with { Name = rename.NewName }; - nameToIndex.Remove(rename.OldName); - nameToIndex[rename.NewName] = renameIdx; - continue; - } - - if (action is DeleteColumnAction delete) - { - nameToIndex.Remove(delete.ColumnName); - continue; - } - - if (action is CastColumnAction cast) - { - if (!nameToIndex.TryGetValue(cast.ColumnName, out var castIdx)) - { - continue; - } - - working[castIdx] = working[castIdx] with { Type = cast.TargetType }; - continue; - } - - if (action is FilterAction filter) - { - // Row-level filter: does not modify column schema. - // Resolve column name to source index and record FilterSpec. - if (!nameToIndex.TryGetValue(filter.ColumnName, out var filterIdx)) - { - continue; - } - - var col = working[filterIdx]; - filterSpecs.Add( - new FilterSpec( - SourceColumnIndex: col.SourceIndex, - ColumnType: col.Type, - Operator: filter.Operator, - Value: filter.Value - ) - ); - continue; - } - - if (action is FillColumnAction fill) - { - if (!nameToIndex.TryGetValue(fill.ColumnName, out var fillIdx)) - { - continue; - } - - var inferredType = TypeInferrer.InferType(fill.Value.AsSpan()); - working[fillIdx] = working[fillIdx] with { FillValue = fill.Value, Type = inferredType }; - continue; - } - - if (action is FormatTimestampAction formatTs) - { - if (!nameToIndex.TryGetValue(formatTs.ColumnName, out var fmtIdx)) - { - continue; - } - - working[fmtIdx] = working[fmtIdx] with { FormatString = formatTs.TargetFormat }; - continue; - } + ApplyAction(action, working, nameToIndex, filterSpecs); } // Pre-size to avoid reallocation; collection expressions do not support capacity hints. @@ -258,58 +186,141 @@ IReadOnlyList filterSpecs ); } + private static void ApplyAction( + MorphAction action, + List working, + Dictionary nameToIndex, + List filterSpecs) + { + switch (action) + { + case RenameColumnAction rename: + ApplyRename(rename, working, nameToIndex); + break; + case DeleteColumnAction delete: + nameToIndex.Remove(delete.ColumnName); + break; + case CastColumnAction cast: + ApplyCast(cast, working, nameToIndex); + break; + case FilterAction filter: + ApplyFilter(filter, working, nameToIndex, filterSpecs); + break; + case FillColumnAction fill: + ApplyFill(fill, working, nameToIndex); + break; + case FormatTimestampAction formatTs: + ApplyFormatTimestamp(formatTs, working, nameToIndex); + break; + } + } + + private static void ApplyRename(RenameColumnAction rename, List working, Dictionary nameToIndex) + { + if (!nameToIndex.TryGetValue(rename.OldName, out var renameIdx)) + { + return; + } + + working[renameIdx] = working[renameIdx] with { Name = rename.NewName }; + nameToIndex.Remove(rename.OldName); + nameToIndex[rename.NewName] = renameIdx; + } + + private static void ApplyCast(CastColumnAction cast, List working, Dictionary nameToIndex) + { + if (!nameToIndex.TryGetValue(cast.ColumnName, out var castIdx)) + { + return; + } + + working[castIdx] = working[castIdx] with { Type = cast.TargetType }; + } + + private static void ApplyFilter( + FilterAction filter, + List working, + Dictionary nameToIndex, + List filterSpecs) + { + // Row-level filter: does not modify column schema. + // Resolve column name to source index and record FilterSpec. + if (!nameToIndex.TryGetValue(filter.ColumnName, out var filterIdx)) + { + return; + } + + var col = working[filterIdx]; + filterSpecs.Add( + new FilterSpec( + SourceColumnIndex: col.SourceIndex, + ColumnType: col.Type, + Operator: filter.Operator, + Value: filter.Value + ) + ); + } + + private static void ApplyFill(FillColumnAction fill, List working, Dictionary nameToIndex) + { + if (!nameToIndex.TryGetValue(fill.ColumnName, out var fillIdx)) + { + return; + } + + var inferredType = TypeInferrer.InferType(fill.Value.AsSpan()); + working[fillIdx] = working[fillIdx] with { FillValue = fill.Value, Type = inferredType }; + } + + private static void ApplyFormatTimestamp( + FormatTimestampAction formatTs, + List working, + Dictionary nameToIndex) + { + if (!nameToIndex.TryGetValue(formatTs.ColumnName, out var fmtIdx)) + { + return; + } + + working[fmtIdx] = working[fmtIdx] with { FormatString = formatTs.TargetFormat }; + } + + private const string ParseFailureLabel = ""; + /// /// Formats a raw cell string value according to the target column type. /// Returns the raw value for , , /// and . Returns "<invalid>" if parsing fails. /// - private static string FormatCellValue(string rawValue, ColumnType targetType, string? formatString) + private static string FormatCellValue(string rawValue, ColumnType targetType, string? formatString) => targetType switch { - const string parseFailureLabel = ""; - switch (targetType) - { - case ColumnType.WholeNumber: - { - if (!long.TryParse(rawValue, out var l)) - { - return parseFailureLabel; - } + ColumnType.WholeNumber => FormatWholeNumber(rawValue), + ColumnType.FloatingPoint => FormatFloatingPoint(rawValue), + ColumnType.Boolean => FormatBoolean(rawValue), + ColumnType.Timestamp => FormatTimestamp(rawValue, formatString), + _ => rawValue, + }; - return l.ToString(CultureInfo.InvariantCulture); - } - case ColumnType.FloatingPoint: - { - if (!double.TryParse(rawValue, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) - { - return parseFailureLabel; - } + private static string FormatWholeNumber(string rawValue) => + long.TryParse(rawValue, out var l) ? l.ToString(CultureInfo.InvariantCulture) : ParseFailureLabel; - return d.ToString(CultureInfo.InvariantCulture); - } - case ColumnType.Boolean: - { - if (!bool.TryParse(rawValue, out var b)) - { - return parseFailureLabel; - } + private static string FormatFloatingPoint(string rawValue) => + double.TryParse(rawValue, NumberStyles.Any, CultureInfo.InvariantCulture, out var d) + ? d.ToString(CultureInfo.InvariantCulture) + : ParseFailureLabel; - return b ? "true" : "false"; - } - case ColumnType.Timestamp: - { - if (!DateTime.TryParse(rawValue, out var dt)) - { - return parseFailureLabel; - } + private static string FormatBoolean(string rawValue) => + bool.TryParse(rawValue, out var b) ? (b ? "true" : "false") : ParseFailureLabel; - var format = string.IsNullOrEmpty(formatString) ? "yyyy-MM-dd HH:mm:ss" : formatString; - return dt.ToString(format, CultureInfo.InvariantCulture); - } - default: - { - return rawValue; - } + private static string FormatTimestamp(string rawValue, string? formatString) + { + if (!DateTime.TryParse(rawValue, out var dt)) + { + return ParseFailureLabel; } + + var format = string.IsNullOrEmpty(formatString) ? "yyyy-MM-dd HH:mm:ss" : formatString; + return dt.ToString(format, CultureInfo.InvariantCulture); } /// diff --git a/src/App/Views/MorphTableView.cs b/src/App/Views/MorphTableView.cs index fb9b323..347fbe2 100644 --- a/src/App/Views/MorphTableView.cs +++ b/src/App/Views/MorphTableView.cs @@ -41,41 +41,54 @@ protected override bool OnKeyDown(Key key) var action = _vimKeys.Translate(key.KeyCode); - void moveToRow(int row) + var command = MapCommand(action); + if (command.HasValue) { - // Cannot use Command.Start/End as they reset the column to 0 or rightmost. - // We need to preserve the current column while moving rows. - if (Value is null) - { - return; - } - - SetSelection(col: Value.SelectedCell.X, row: row, extendExistingSelection: false); - Update(); - SetNeedsDraw(); - } - - static bool execute(Action a) - { - a(); + InvokeCommand(command.Value); return true; } return action switch { - VimAction.MoveDown => execute(() => InvokeCommand(Command.Down)), - VimAction.MoveUp => execute(() => InvokeCommand(Command.Up)), - VimAction.MoveLeft => execute(() => InvokeCommand(Command.Left)), - VimAction.MoveRight => execute(() => InvokeCommand(Command.Right)), - VimAction.PageDown => execute(() => InvokeCommand(Command.PageDown)), - VimAction.PageUp => execute(() => InvokeCommand(Command.PageUp)), - VimAction.GoToFirst => execute(() => moveToRow(0)), - VimAction.GoToEnd => execute(() => moveToRow(Table.Rows - 1)), + VimAction.GoToFirst => ConsumeRow(0), + VimAction.GoToEnd => ConsumeRow(Table.Rows - 1), VimAction.PendingGSequence => true, _ => HandleNonVimKey(key), }; } + private static Command? MapCommand(VimAction action) => + action switch + { + VimAction.MoveDown => Command.Down, + VimAction.MoveUp => Command.Up, + VimAction.MoveLeft => Command.Left, + VimAction.MoveRight => Command.Right, + VimAction.PageDown => Command.PageDown, + VimAction.PageUp => Command.PageUp, + _ => null, + }; + + private bool ConsumeRow(int row) + { + MoveToRow(row); + return true; + } + + // Cannot use Command.Start/End as they reset the column to 0 or rightmost. + // We need to preserve the current column while moving rows. + private void MoveToRow(int row) + { + if (Value is null) + { + return; + } + + SetSelection(col: Value.SelectedCell.X, row: row, extendExistingSelection: false); + Update(); + SetNeedsDraw(); + } + private bool HandleNonVimKey(Key key) { // Prevent global shortcut keys from being consumed by TableView's incremental search. diff --git a/src/App/Views/MorphTreeView.cs b/src/App/Views/MorphTreeView.cs index 2ba2340..beb7bd0 100644 --- a/src/App/Views/MorphTreeView.cs +++ b/src/App/Views/MorphTreeView.cs @@ -55,21 +55,14 @@ protected override bool OnKeyDown(Key key) var action = _vimKeys.Translate(key.KeyCode); + if (TrySelectionMove(action)) + { + return true; + } + return action switch { VimAction.PendingGSequence => true, - VimAction.MoveDown => ConsumeAction(() => - AdjustSelection(offset: 1, expandSelection: false) - ), - VimAction.MoveUp => ConsumeAction(() => - AdjustSelection(offset: -1, expandSelection: false) - ), - VimAction.PageDown => ConsumeAction(() => - AdjustSelection(offset: Viewport.Height, expandSelection: false) - ), - VimAction.PageUp => ConsumeAction(() => - AdjustSelection(offset: -Viewport.Height, expandSelection: false) - ), VimAction.MoveLeft => base.OnKeyDown(new Key(KeyCode.CursorLeft)), VimAction.MoveRight => base.OnKeyDown(new Key(KeyCode.CursorRight)), VimAction.GoToFirst => ConsumeAction(GoToFirst), @@ -78,6 +71,27 @@ protected override bool OnKeyDown(Key key) }; } + // Groups the four selection-offset moves so OnKeyDown's dispatch stays under the complexity cap. + private bool TrySelectionMove(VimAction action) + { + var offset = action switch + { + VimAction.MoveDown => 1, + VimAction.MoveUp => -1, + VimAction.PageDown => Viewport.Height, + VimAction.PageUp => -Viewport.Height, + _ => (int?)null, + }; + + if (offset is null) + { + return false; + } + + AdjustSelection(offset: offset.Value, expandSelection: false); + return true; + } + private bool HandleNonVimKey(Key key) { // Prevent global shortcut keys from being consumed by TreeView's incremental search. diff --git a/src/App/Views/VimKeyTranslator.cs b/src/App/Views/VimKeyTranslator.cs index 35ea7de..cf34078 100644 --- a/src/App/Views/VimKeyTranslator.cs +++ b/src/App/Views/VimKeyTranslator.cs @@ -87,7 +87,11 @@ internal VimAction Translate(KeyCode keyCode) return VimAction.None; } - return keyCode switch + return TranslateBasicKey(keyCode); + } + + private static VimAction TranslateBasicKey(KeyCode keyCode) => + keyCode switch { KeyCode.H => VimAction.MoveLeft, KeyCode.J => VimAction.MoveDown, @@ -97,5 +101,4 @@ internal VimAction Translate(KeyCode keyCode) KeyCode.U => VimAction.PageUp, _ => VimAction.None, }; - } } diff --git a/src/Engine/ActionApplier.cs b/src/Engine/ActionApplier.cs index eb649ec..788991f 100644 --- a/src/Engine/ActionApplier.cs +++ b/src/Engine/ActionApplier.cs @@ -45,98 +45,147 @@ IReadOnlyList actions List filterSpecs = []; Dictionary transformsByWorkingIndex = []; - // Process actions in order foreach (var action in actions) { - if (action is RenameColumnAction rename) + var result = ApplyAction(action, workingColumns, nameToWorkingIndex, filterSpecs, transformsByWorkingIndex); + if (result.IsFailure) { - if (!nameToWorkingIndex.TryGetValue(rename.OldName, out var idx)) - { - continue; - } - - var (name, type, columnIndex, _) = workingColumns[idx]; - workingColumns[idx] = (name, type, columnIndex, rename.NewName); - nameToWorkingIndex.Remove(rename.OldName); - nameToWorkingIndex[rename.NewName] = idx; - continue; + return Results.Failure(result.Error); } + } - if (action is DeleteColumnAction delete) - { - if (!nameToWorkingIndex.TryGetValue(delete.ColumnName, out _)) - { - continue; - } + var outputColumns = BuildOutputColumns(workingColumns, nameToWorkingIndex, transformsByWorkingIndex); + return Results.Success(new BatchOutputSchema(outputColumns, filterSpecs)); + } - nameToWorkingIndex.Remove(delete.ColumnName); - continue; - } + private static Result ApplyAction( + MorphAction action, + List<(string Name, ColumnType Type, int ColumnIndex, string OutputName)> workingColumns, + Dictionary nameToWorkingIndex, + List filterSpecs, + Dictionary transformsByWorkingIndex + ) => + action switch + { + RenameColumnAction rename => ApplyRename(rename, workingColumns, nameToWorkingIndex), + DeleteColumnAction delete => ApplyDelete(delete, nameToWorkingIndex), + CastColumnAction cast => ApplyCast(cast, workingColumns, nameToWorkingIndex), + FilterAction filter => ApplyFilter(filter, workingColumns, nameToWorkingIndex, filterSpecs), + FillColumnAction fill => ApplyFill(fill, nameToWorkingIndex, transformsByWorkingIndex), + FormatTimestampAction formatTimestamp + => ApplyFormatTimestamp(formatTimestamp, workingColumns, nameToWorkingIndex, transformsByWorkingIndex), + _ => throw new UnreachableException($"Unhandled action type: {action.GetType().Name}"), + }; + + private static Result ApplyRename( + RenameColumnAction rename, + List<(string Name, ColumnType Type, int ColumnIndex, string OutputName)> workingColumns, + Dictionary nameToWorkingIndex + ) + { + if (!nameToWorkingIndex.TryGetValue(rename.OldName, out var idx)) + { + return Results.Success(); + } - if (action is CastColumnAction cast) - { - if (!nameToWorkingIndex.TryGetValue(cast.ColumnName, out var idx)) - { - continue; - } - - var (name, _, columnIndex, outputName) = workingColumns[idx]; - workingColumns[idx] = (name, cast.TargetType, columnIndex, outputName); - continue; - } + var (name, type, columnIndex, _) = workingColumns[idx]; + workingColumns[idx] = (name, type, columnIndex, rename.NewName); + nameToWorkingIndex.Remove(rename.OldName); + nameToWorkingIndex[rename.NewName] = idx; + return Results.Success(); + } - if (action is FilterAction filter) - { - if (!nameToWorkingIndex.TryGetValue(filter.ColumnName, out var idx)) - { - continue; - } - - var (_, type, columnIndex, _) = workingColumns[idx]; - filterSpecs.Add( - new FilterSpec( - SourceColumnIndex: columnIndex, - ColumnType: type, - Operator: filter.Operator, - Value: filter.Value - ) - ); - continue; - } + private static Result ApplyDelete(DeleteColumnAction delete, Dictionary nameToWorkingIndex) + { + nameToWorkingIndex.Remove(delete.ColumnName); + return Results.Success(); + } - if (action is FillColumnAction fill) - { - if (!nameToWorkingIndex.TryGetValue(fill.ColumnName, out var idx)) - { - continue; - } + private static Result ApplyCast( + CastColumnAction cast, + List<(string Name, ColumnType Type, int ColumnIndex, string OutputName)> workingColumns, + Dictionary nameToWorkingIndex + ) + { + if (!nameToWorkingIndex.TryGetValue(cast.ColumnName, out var idx)) + { + return Results.Success(); + } - transformsByWorkingIndex[idx] = new FillSpec(fill.Value); - continue; - } + var (name, _, columnIndex, outputName) = workingColumns[idx]; + workingColumns[idx] = (name, cast.TargetType, columnIndex, outputName); + return Results.Success(); + } - if (action is FormatTimestampAction formatTimestamp) - { - if (!nameToWorkingIndex.TryGetValue(formatTimestamp.ColumnName, out var idx)) - { - continue; - } - - var (_, type, _, _) = workingColumns[idx]; - if (type != ColumnType.Timestamp) - { - return Results.Failure( - $"FormatTimestampAction requires column '{formatTimestamp.ColumnName}' to be of type Timestamp, but it is {type}."); - } - - transformsByWorkingIndex[idx] = new TimestampFormatSpec(formatTimestamp.TargetFormat); - continue; - } + private static Result ApplyFilter( + FilterAction filter, + List<(string Name, ColumnType Type, int ColumnIndex, string OutputName)> workingColumns, + Dictionary nameToWorkingIndex, + List filterSpecs + ) + { + if (!nameToWorkingIndex.TryGetValue(filter.ColumnName, out var idx)) + { + return Results.Success(); + } + + var (_, type, columnIndex, _) = workingColumns[idx]; + filterSpecs.Add( + new FilterSpec( + SourceColumnIndex: columnIndex, + ColumnType: type, + Operator: filter.Operator, + Value: filter.Value + ) + ); + return Results.Success(); + } - throw new UnreachableException($"Unhandled action type: {action.GetType().Name}"); + private static Result ApplyFill( + FillColumnAction fill, + Dictionary nameToWorkingIndex, + Dictionary transformsByWorkingIndex + ) + { + if (!nameToWorkingIndex.TryGetValue(fill.ColumnName, out var idx)) + { + return Results.Success(); } - // Build remaining columns in order (filter out deleted columns) + transformsByWorkingIndex[idx] = new FillSpec(fill.Value); + return Results.Success(); + } + + private static Result ApplyFormatTimestamp( + FormatTimestampAction formatTimestamp, + List<(string Name, ColumnType Type, int ColumnIndex, string OutputName)> workingColumns, + Dictionary nameToWorkingIndex, + Dictionary transformsByWorkingIndex + ) + { + if (!nameToWorkingIndex.TryGetValue(formatTimestamp.ColumnName, out var idx)) + { + return Results.Success(); + } + + var (_, type, _, _) = workingColumns[idx]; + if (type != ColumnType.Timestamp) + { + return Results.Failure( + $"FormatTimestampAction requires column '{formatTimestamp.ColumnName}' to be of type Timestamp, but it is {type}."); + } + + transformsByWorkingIndex[idx] = new TimestampFormatSpec(formatTimestamp.TargetFormat); + return Results.Success(); + } + + // Filters out deleted columns and preserves working-column order. + private static List BuildOutputColumns( + List<(string Name, ColumnType Type, int ColumnIndex, string OutputName)> workingColumns, + Dictionary nameToWorkingIndex, + Dictionary transformsByWorkingIndex + ) + { List outputColumns = []; foreach (var kvp in nameToWorkingIndex.OrderBy(kvp => kvp.Value)) { @@ -145,6 +194,6 @@ IReadOnlyList actions outputColumns.Add(new BatchOutputColumn(SourceName: name, OutputName: outputName, Transform: transform)); } - return Results.Success(new BatchOutputSchema(outputColumns, filterSpecs)); + return outputColumns; } } diff --git a/src/Engine/Filtering/FilterEvaluator.cs b/src/Engine/Filtering/FilterEvaluator.cs index a7dfb68..9276895 100644 --- a/src/Engine/Filtering/FilterEvaluator.cs +++ b/src/Engine/Filtering/FilterEvaluator.cs @@ -23,48 +23,49 @@ public static class FilterEvaluator public static bool EvaluateFilter(ReadOnlySpan rawValue, FilterSpec spec) { var op = spec.Operator; + var specValue = spec.Value.AsSpan(); - if (op == FilterOperator.Contains) + if (IsStringOperator(op)) { - return rawValue.Contains(spec.Value.AsSpan(), StringComparison.OrdinalIgnoreCase); - } - - if (op == FilterOperator.NotContains) - { - return !rawValue.Contains(spec.Value.AsSpan(), StringComparison.OrdinalIgnoreCase); - } - - if (op == FilterOperator.StartsWith) - { - return rawValue.StartsWith(spec.Value.AsSpan(), StringComparison.OrdinalIgnoreCase); - } - - if (op == FilterOperator.EndsWith) - { - return rawValue.EndsWith(spec.Value.AsSpan(), StringComparison.OrdinalIgnoreCase); - } - - if (op == FilterOperator.Equals) - { - return rawValue.Equals(spec.Value.AsSpan(), StringComparison.OrdinalIgnoreCase); - } - - if (op == FilterOperator.NotEquals) - { - return !rawValue.Equals(spec.Value.AsSpan(), StringComparison.OrdinalIgnoreCase); + return EvaluateStringOperator(rawValue, specValue, op); } // Numeric/Timestamp comparison operators return spec.ColumnType switch { - ColumnType.WholeNumber => EvaluateNumericLong(rawValue, spec.Value.AsSpan(), op), - ColumnType.FloatingPoint => EvaluateNumericDouble(rawValue, spec.Value.AsSpan(), op), - ColumnType.Timestamp => EvaluateTimestamp(rawValue, spec.Value.AsSpan(), op), + ColumnType.WholeNumber => EvaluateNumericLong(rawValue, specValue, op), + ColumnType.FloatingPoint => EvaluateNumericDouble(rawValue, specValue, op), + ColumnType.Timestamp => EvaluateTimestamp(rawValue, specValue, op), // Text or other types: numeric/timestamp operators are not supported; exclude the row _ => false, }; } + private static bool IsStringOperator(FilterOperator op) => + op is FilterOperator.Contains or FilterOperator.NotContains + or FilterOperator.StartsWith or FilterOperator.EndsWith + or FilterOperator.Equals or FilterOperator.NotEquals; + + private static bool EvaluateStringOperator( + ReadOnlySpan rawValue, + ReadOnlySpan specValue, + FilterOperator op + ) + { + var ignoreCase = StringComparison.OrdinalIgnoreCase; + return op switch + { + FilterOperator.Contains => rawValue.Contains(specValue, ignoreCase), + FilterOperator.NotContains => !rawValue.Contains(specValue, ignoreCase), + FilterOperator.StartsWith => rawValue.StartsWith(specValue, ignoreCase), + FilterOperator.EndsWith => rawValue.EndsWith(specValue, ignoreCase), + FilterOperator.Equals => rawValue.Equals(specValue, ignoreCase), + FilterOperator.NotEquals => !rawValue.Equals(specValue, ignoreCase), + // Non-string operators are handled by the numeric/timestamp path; defensive fallback. + _ => false, + }; + } + private static bool EvaluateNumericLong( ReadOnlySpan rawValue, ReadOnlySpan specValue, diff --git a/src/Engine/IO/ColumnTypeResolver.cs b/src/Engine/IO/ColumnTypeResolver.cs index f9f457a..f86020f 100644 --- a/src/Engine/IO/ColumnTypeResolver.cs +++ b/src/Engine/IO/ColumnTypeResolver.cs @@ -18,66 +18,25 @@ public static class ColumnTypeResolver /// public static ColumnType Resolve(ColumnType current, ColumnType observed) { - // Handle the case where current and observed are the same if (current == observed) { return current; } - // Text is the universal fallback - absorbs all other types - if (current == ColumnType.Text || observed == ColumnType.Text) + // Text/JsonObject/JsonArray are universal fallbacks - any mix with another type absorbs to Text. + if (IsUniversalFallback(current) || IsUniversalFallback(observed)) { return ColumnType.Text; } - // Structured JSON types: same-type is stable; any other combination → Text - if ( - current == ColumnType.JsonObject - || current == ColumnType.JsonArray - || observed == ColumnType.JsonObject - || observed == ColumnType.JsonArray - ) - { - return ColumnType.Text; - } - - // Handle numeric promotions - if (current == ColumnType.WholeNumber && observed == ColumnType.FloatingPoint) - { - return ColumnType.FloatingPoint; - } - - if (current == ColumnType.FloatingPoint && observed == ColumnType.WholeNumber) - { - return ColumnType.FloatingPoint; - } - - // Handle incompatible type combinations (resulting in Text) - // Boolean + anything else (except Boolean) -> Text - if (current == ColumnType.Boolean || observed == ColumnType.Boolean) - { - return ColumnType.Text; - } - - // Timestamp + anything else (except Timestamp) -> Text - if (current == ColumnType.Timestamp || observed == ColumnType.Timestamp) - { - return ColumnType.Text; - } - - // WholeNumber + any other incompatible type -> Text - if (current == ColumnType.WholeNumber || observed == ColumnType.WholeNumber) - { - return ColumnType.Text; - } + // The only compatible mix among the remaining numeric/scalar types is WholeNumber+FloatingPoint. + return IsNumericPromotion(current, observed) ? ColumnType.FloatingPoint : ColumnType.Text; + } - // FloatingPoint + any other incompatible type -> Text - if (current == ColumnType.FloatingPoint || observed == ColumnType.FloatingPoint) - { - return ColumnType.Text; - } + private static bool IsUniversalFallback(ColumnType type) => + type is ColumnType.Text or ColumnType.JsonObject or ColumnType.JsonArray; - // Default fallback - should not reach here as all cases are covered - return ColumnType.Text; - } + private static bool IsNumericPromotion(ColumnType current, ColumnType observed) => + (current == ColumnType.WholeNumber && observed == ColumnType.FloatingPoint) + || (current == ColumnType.FloatingPoint && observed == ColumnType.WholeNumber); } diff --git a/src/Engine/IO/Csv/DataRowReader.cs b/src/Engine/IO/Csv/DataRowReader.cs index 0148570..d415961 100644 --- a/src/Engine/IO/Csv/DataRowReader.cs +++ b/src/Engine/IO/Csv/DataRowReader.cs @@ -40,12 +40,7 @@ public IReadOnlyList ReadRows(long byteOffset, int rowsToSkip, int r { ObjectDisposedException.ThrowIf(_disposed, this); - if (byteOffset < 0) - { - return []; - } - - if (rowsToRead <= 0) + if (byteOffset < 0 || rowsToRead <= 0) { return []; } @@ -59,54 +54,8 @@ public IReadOnlyList ReadRows(long byteOffset, int rowsToSkip, int r using var streamReader = new StreamReader(_fileStream, Encoding.UTF8, leaveOpen: true); using var reader = Sep.New(',').Reader(o => o with { HasHeader = false }).From(streamReader); - // Skip rows until the actual start row - var skipped = 0; - while (skipped < rowsToSkip && reader.MoveNext()) - { - skipped++; - } - - // Read the requested number of rows - var readCount = 0; - while (readCount < rowsToRead && reader.MoveNext()) - { - var record = reader.Current; - - // Calculate total buffer size needed - var totalLength = 0; - for (var i = 0; i < _columnCount; i++) - { - if (i < record.ColCount) - { - totalLength += record[i].Span.Length; - } - } - - // Allocate single buffer for all columns - var buffer = new char[totalLength]; - var columns = new ReadOnlyMemory[_columnCount]; - var bufferPos = 0; - - // Copy column data to buffer and create ReadOnlyMemory slices - for (var i = 0; i < _columnCount; i++) - { - if (i < record.ColCount) - { - var colSpan = record[i].Span; - colSpan.CopyTo(buffer.AsSpan(bufferPos, colSpan.Length)); - columns[i] = new ReadOnlyMemory(buffer, bufferPos, colSpan.Length); - bufferPos += colSpan.Length; - } - else - { - // Empty column - columns[i] = ReadOnlyMemory.Empty; - } - } - - rows.Add(columns); - readCount++; - } + SkipRows(reader, rowsToSkip); + ReadRowsInto(reader, rowsToRead, rows); } catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException) { @@ -117,6 +66,60 @@ public IReadOnlyList ReadRows(long byteOffset, int rowsToSkip, int r return rows; } + private static void SkipRows(SepReader reader, int rowsToSkip) + { + var skipped = 0; + while (skipped < rowsToSkip && reader.MoveNext()) + { + skipped++; + } + } + + private void ReadRowsInto(SepReader reader, int rowsToRead, List rows) + { + var readCount = 0; + while (readCount < rowsToRead && reader.MoveNext()) + { + var record = reader.Current; + rows.Add(ParseRow(record)); + readCount++; + } + } + + // Single-pass total length calculation, then a second pass to copy into one shared buffer - + // avoids one allocation per column. + private ReadOnlyMemory[] ParseRow(in SepReader.Row record) + { + var totalLength = 0; + for (var i = 0; i < _columnCount; i++) + { + if (i < record.ColCount) + { + totalLength += record[i].Span.Length; + } + } + + var buffer = new char[totalLength]; + var columns = new ReadOnlyMemory[_columnCount]; + var bufferPos = 0; + + for (var i = 0; i < _columnCount; i++) + { + if (i >= record.ColCount) + { + columns[i] = ReadOnlyMemory.Empty; + continue; + } + + var colSpan = record[i].Span; + colSpan.CopyTo(buffer.AsSpan(bufferPos, colSpan.Length)); + columns[i] = new ReadOnlyMemory(buffer, bufferPos, colSpan.Length); + bufferPos += colSpan.Length; + } + + return columns; + } + /// public void Dispose() { diff --git a/src/Engine/IO/DrillDown/KeyPathTraverser.cs b/src/Engine/IO/DrillDown/KeyPathTraverser.cs index 8c02c08..91ed3f7 100644 --- a/src/Engine/IO/DrillDown/KeyPathTraverser.cs +++ b/src/Engine/IO/DrillDown/KeyPathTraverser.cs @@ -76,41 +76,9 @@ private static void TraverseKeyPath( if (segment.Kind == KeyPathSegmentKind.Index) { - var reader = new Utf8JsonReader(currentBytes.Span); - if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) - { - return; // Wrong type at this path position — skip record silently. - } - - if (segmentIndex == keyPath.Count - 1) - { - // A trailing index segment expands the same array that would be reached by - // selecting it directly as the leaf (e.g. "tags" and "tags[0]" must produce - // identical output, including the "value" column for primitive elements). - CollectArrayLeafRows(currentBytes, posHash, rows, keyOrder, keySet, columnTypes, keyObservedCount); - return; - } - - var elementIndex = 0; - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndArray) - { - break; - } - - if (reader.CurrentDepth != 1) - { - continue; - } - - var elementBytes = ExtractElementBytes(ref reader, currentBytes); - TraverseKeyPath( - elementBytes, keyPath, segmentIndex + 1, $"{posHash}:{elementIndex}", colName, colNameUtf8, - rows, keyOrder, keySet, columnTypes, keyObservedCount); - elementIndex++; - } - + TraverseIndexSegment( + currentBytes, keyPath, segmentIndex, posHash, colName, colNameUtf8, + rows, keyOrder, keySet, columnTypes, keyObservedCount); return; } @@ -125,6 +93,55 @@ private static void TraverseKeyPath( rows, keyOrder, keySet, columnTypes, keyObservedCount); } + private static void TraverseIndexSegment( + JsonRawBytes currentBytes, + IReadOnlyList keyPath, + int segmentIndex, + string posHash, + string colName, + byte[] colNameUtf8, + List rows, + List keyOrder, + HashSet keySet, + Dictionary columnTypes, + Dictionary keyObservedCount) + { + var reader = new Utf8JsonReader(currentBytes.Span); + if (!reader.Read() || reader.TokenType != JsonTokenType.StartArray) + { + return; // Wrong type at this path position — skip record silently. + } + + if (segmentIndex == keyPath.Count - 1) + { + // A trailing index segment expands the same array that would be reached by + // selecting it directly as the leaf (e.g. "tags" and "tags[0]" must produce + // identical output, including the "value" column for primitive elements). + CollectArrayLeafRows(currentBytes, posHash, rows, keyOrder, keySet, columnTypes, keyObservedCount); + return; + } + + var elementIndex = 0; + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + + if (reader.CurrentDepth != 1) + { + continue; + } + + var elementBytes = ExtractElementBytes(ref reader, currentBytes); + TraverseKeyPath( + elementBytes, keyPath, segmentIndex + 1, $"{posHash}:{elementIndex}", colName, colNameUtf8, + rows, keyOrder, keySet, columnTypes, keyObservedCount); + elementIndex++; + } + } + private static void CollectLeafRows( JsonRawBytes leafBytes, string posHash, diff --git a/src/Engine/IO/JsonArray/ElementReader.cs b/src/Engine/IO/JsonArray/ElementReader.cs index aa90035..e073de9 100644 --- a/src/Engine/IO/JsonArray/ElementReader.cs +++ b/src/Engine/IO/JsonArray/ElementReader.cs @@ -99,34 +99,9 @@ private void FetchElements( throw new NotSupportedException("JSON element exceeds maximum supported size."); } - int dataEnd; - - if (firstFill) - { - firstFill = false; - buffer[0] = (byte)'['; - var available = _mmap.Length - fileReadOffset; - var toRead = (int)Math.Min(BufferSize - 1, Math.Max(0L, available)); - if (toRead > 0) - { - _mmap.Read(fileReadOffset, buffer.AsSpan(1, toRead)); - } - - fileReadOffset += toRead; - dataEnd = 1 + toRead; - } - else - { - var available = _mmap.Length - fileReadOffset; - var toRead = (int)Math.Min(BufferSize - remainingLen, Math.Max(0L, available)); - if (toRead > 0) - { - _mmap.Read(fileReadOffset, buffer.AsSpan(remainingLen, toRead)); - } - - fileReadOffset += toRead; - dataEnd = remainingLen + toRead; - } + var (dataEnd, newFileReadOffset) = FillBuffer(buffer, fileReadOffset, remainingLen, firstFill); + fileReadOffset = newFileReadOffset; + firstFill = false; var isFinalBlock = fileReadOffset >= _mmap.Length; @@ -136,52 +111,13 @@ private void FetchElements( } var reader = new Utf8JsonReader(buffer.AsSpan(0, dataEnd), isFinalBlock, state); - var rootDone = false; + var scanResult = ScanBufferForElements( + ref reader, bufferOriginFileOffset, currentElementStartFile, elementsEncountered, + elementsToSkip, elementsToFetch, result); + currentElementStartFile = scanResult.currentElementStartFile; + elementsEncountered = scanResult.elementsEncountered; - while (reader.Read()) - { - if (reader.CurrentDepth == 0 && reader.TokenType == JsonTokenType.EndArray) - { - rootDone = true; - break; - } - - if (reader.CurrentDepth != 1) - { - continue; - } - - if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) - { - currentElementStartFile = bufferOriginFileOffset + reader.TokenStartIndex; - continue; - } - - if (reader.TokenType is JsonTokenType.EndObject or JsonTokenType.EndArray) - { - var endFile = bufferOriginFileOffset + reader.BytesConsumed; - elementsEncountered = AppendElement(currentElementStartFile, endFile, elementsEncountered, elementsToSkip, result); - currentElementStartFile = -1L; - if (result.Count >= elementsToFetch) - { - break; - } - - continue; - } - - // Primitive element at depth 1 (number, string, bool, null). - elementsEncountered = AppendElement( - bufferOriginFileOffset + reader.TokenStartIndex, - bufferOriginFileOffset + reader.BytesConsumed, - elementsEncountered, elementsToSkip, result); - if (result.Count >= elementsToFetch) - { - break; - } - } - - if (rootDone || result.Count >= elementsToFetch || isFinalBlock) + if (scanResult.rootDone || result.Count >= elementsToFetch || isFinalBlock) { break; } @@ -198,6 +134,94 @@ private void FetchElements( } } + // buffer[0] holds a synthetic '[' on the first fill only, matching bufferOriginFileOffset's + // -1 adjustment so token offsets resolve correctly relative to the real file. + private (int dataEnd, long fileReadOffset) FillBuffer(byte[] buffer, long fileReadOffset, int remainingLen, bool firstFill) + { + if (firstFill) + { + buffer[0] = (byte)'['; + var available = _mmap.Length - fileReadOffset; + var toRead = (int)Math.Min(BufferSize - 1, Math.Max(0L, available)); + if (toRead > 0) + { + _mmap.Read(fileReadOffset, buffer.AsSpan(1, toRead)); + } + + return (1 + toRead, fileReadOffset + toRead); + } + + var availableRest = _mmap.Length - fileReadOffset; + var toReadRest = (int)Math.Min(BufferSize - remainingLen, Math.Max(0L, availableRest)); + if (toReadRest > 0) + { + _mmap.Read(fileReadOffset, buffer.AsSpan(remainingLen, toReadRest)); + } + + return (remainingLen + toReadRest, fileReadOffset + toReadRest); + } + + private (bool rootDone, long currentElementStartFile, int elementsEncountered) ScanBufferForElements( + ref Utf8JsonReader reader, + long bufferOriginFileOffset, + long currentElementStartFile, + int elementsEncountered, + int elementsToSkip, + int elementsToFetch, + List result) + { + while (reader.Read()) + { + if (reader.CurrentDepth == 0 && reader.TokenType == JsonTokenType.EndArray) + { + return (true, currentElementStartFile, elementsEncountered); + } + + if (reader.CurrentDepth != 1) + { + continue; + } + + (currentElementStartFile, elementsEncountered) = ProcessDepth1Token( + ref reader, bufferOriginFileOffset, currentElementStartFile, elementsEncountered, elementsToSkip, result); + + if (result.Count >= elementsToFetch) + { + break; + } + } + + return (false, currentElementStartFile, elementsEncountered); + } + + private (long currentElementStartFile, int elementsEncountered) ProcessDepth1Token( + ref Utf8JsonReader reader, + long bufferOriginFileOffset, + long currentElementStartFile, + int elementsEncountered, + int elementsToSkip, + List result) + { + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + return (bufferOriginFileOffset + reader.TokenStartIndex, elementsEncountered); + } + + if (reader.TokenType is JsonTokenType.EndObject or JsonTokenType.EndArray) + { + var endFile = bufferOriginFileOffset + reader.BytesConsumed; + var updatedEncountered = AppendElement(currentElementStartFile, endFile, elementsEncountered, elementsToSkip, result); + return (-1L, updatedEncountered); + } + + // Primitive element at depth 1 (number, string, bool, null). + var encounteredAfterPrimitive = AppendElement( + bufferOriginFileOffset + reader.TokenStartIndex, + bufferOriginFileOffset + reader.BytesConsumed, + elementsEncountered, elementsToSkip, result); + return (currentElementStartFile, encounteredAfterPrimitive); + } + private int AppendElement( long startFile, long endFile, diff --git a/src/Engine/IO/JsonArray/RowIndexer.cs b/src/Engine/IO/JsonArray/RowIndexer.cs index 23fee43..0427b9a 100644 --- a/src/Engine/IO/JsonArray/RowIndexer.cs +++ b/src/Engine/IO/JsonArray/RowIndexer.cs @@ -120,50 +120,11 @@ public override void BuildIndex(CancellationToken ct = default) state ); - var rootArrayComplete = false; + var scanResult = ScanBufferForElements(ref reader, bufferOriginFileOffset, currentElementStart, elementCount); + currentElementStart = scanResult.currentElementStart; + elementCount = scanResult.elementCount; - while (reader.Read()) - { - if (reader.CurrentDepth == 0 && reader.TokenType == JsonTokenType.EndArray) - { - rootArrayComplete = true; - break; - } - - if (reader.CurrentDepth != 1) - { - continue; - } - - // Depth-1 tokens — element boundary detection - if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) - { - // Guard against overwriting an already-set start position. - // In valid JSON this condition is always true, but the check - // prevents silent data corruption if this invariant is ever violated. - if (currentElementStart < 0) - { - currentElementStart = - bufferOriginFileOffset + reader.TokenStartIndex; - } - - continue; - } - - if (reader.TokenType is JsonTokenType.EndObject or JsonTokenType.EndArray) - { - RecordElement(currentElementStart, elementCount); - elementCount++; - currentElementStart = -1L; - continue; - } - - // Primitive token at depth 1 — self-contained element - RecordElement(bufferOriginFileOffset + reader.TokenStartIndex, elementCount); - elementCount++; - } - - if (rootArrayComplete) + if (scanResult.rootArrayComplete) { break; } @@ -188,6 +149,57 @@ public override void BuildIndex(CancellationToken ct = default) } } + // Scans depth-1 tokens in the current buffer for element boundaries, recording each + // completed element. currentElementStart/elementCount are threaded through by value since + // an element may span a buffer boundary (state carries over to the next buffer's scan). + private (bool rootArrayComplete, long currentElementStart, long elementCount) ScanBufferForElements( + ref Utf8JsonReader reader, + long bufferOriginFileOffset, + long currentElementStart, + long elementCount) + { + while (reader.Read()) + { + if (reader.CurrentDepth == 0 && reader.TokenType == JsonTokenType.EndArray) + { + return (true, currentElementStart, elementCount); + } + + if (reader.CurrentDepth != 1) + { + continue; + } + + // Depth-1 tokens — element boundary detection + if (reader.TokenType is JsonTokenType.StartObject or JsonTokenType.StartArray) + { + // Guard against overwriting an already-set start position. + // In valid JSON this condition is always true, but the check + // prevents silent data corruption if this invariant is ever violated. + if (currentElementStart < 0) + { + currentElementStart = bufferOriginFileOffset + reader.TokenStartIndex; + } + + continue; + } + + if (reader.TokenType is JsonTokenType.EndObject or JsonTokenType.EndArray) + { + RecordElement(currentElementStart, elementCount); + elementCount++; + currentElementStart = -1L; + continue; + } + + // Primitive token at depth 1 — self-contained element + RecordElement(bufferOriginFileOffset + reader.TokenStartIndex, elementCount); + elementCount++; + } + + return (false, currentElementStart, elementCount); + } + private void RecordElement(long elementStart, long elementCount) { if (elementCount == 0) diff --git a/src/Engine/IO/JsonLines/RowReader.cs b/src/Engine/IO/JsonLines/RowReader.cs index a0fff9a..d9d8736 100644 --- a/src/Engine/IO/JsonLines/RowReader.cs +++ b/src/Engine/IO/JsonLines/RowReader.cs @@ -63,10 +63,21 @@ int linesToRead } } - var skipped = 0; + var skipResult = SkipLines(currentOffset, linesToSkip); + if (!skipResult.reachedTarget) + { + // No more data to skip - when trying to skip beyond EOF, there are no lines to read + return result; + } + + ReadRequestedLines(skipResult.offset, linesToRead, result); + return result; + } - // Skip lines if needed - var skipLineStartOffset = currentOffset; + private (bool reachedTarget, long offset) SkipLines(long startOffset, int linesToSkip) + { + var skipped = 0; + var skipLineStartOffset = startOffset; var skipIncompleteBytes = 0L; while (skipped < linesToSkip) @@ -75,8 +86,7 @@ int linesToRead // bytesConsumed <= 0 indicates EOF or error if (bytesConsumed <= 0) { - // No more data to skip - when trying to skip beyond EOF, there are no lines to read - return result; + return (false, skipLineStartOffset); } if (!lineCompleted) @@ -98,9 +108,12 @@ int linesToRead skipped++; } - currentOffset = skipLineStartOffset; + return (true, skipLineStartOffset); + } - // Read requested lines + private void ReadRequestedLines(long startOffset, int linesToRead, List result) + { + var currentOffset = startOffset; var linesRead = 0; var incompleteLineBytes = 0L; @@ -110,7 +123,7 @@ int linesToRead if (bytesConsumed <= 0) { HandleIncompleteLineAtEof(currentOffset, incompleteLineBytes, result); - return result; + return; } if (!lineCompleted) @@ -147,8 +160,6 @@ int linesToRead incompleteLineBytes = 0; linesRead++; } - - return result; } private static bool HasUtf8Bom(ReadOnlySpan header) diff --git a/src/Engine/IO/JsonLines/SchemaScanner.cs b/src/Engine/IO/JsonLines/SchemaScanner.cs index 71576a8..6f66433 100644 --- a/src/Engine/IO/JsonLines/SchemaScanner.cs +++ b/src/Engine/IO/JsonLines/SchemaScanner.cs @@ -110,7 +110,23 @@ public static Result RefineSchema(TableSchema schema, ReadOnlySpan< return Results.Success(schema); } - // Copy-on-Write: collect only changed/new columns + var updatedColumns = ComputeUpdatedColumns(schema, keyOrder, columnMap, observedKeys); + if (updatedColumns.Count == 0) + { + return Results.Success(schema); + } + + var newColumns = RebuildColumns(schema, keyOrder, updatedColumns); + return Results.Success(schema with { Columns = newColumns }); + } + + // Copy-on-Write: collects only columns whose type or nullability changed relative to schema. + private static Dictionary ComputeUpdatedColumns( + TableSchema schema, + List keyOrder, + Dictionary columnMap, + HashSet observedKeys) + { var updatedColumns = new Dictionary(); for (var i = 0; i < keyOrder.Count; i++) @@ -145,12 +161,15 @@ public static Result RefineSchema(TableSchema schema, ReadOnlySpan< } } - if (updatedColumns.Count == 0) - { - return Results.Success(schema); - } + return updatedColumns; + } - // Rebuild full column list with updated entries + // Rebuilds the full column list in keyOrder, substituting any updated entries. + private static List RebuildColumns( + TableSchema schema, + List keyOrder, + Dictionary updatedColumns) + { var newColumns = new List(keyOrder.Count); for (var i = 0; i < keyOrder.Count; i++) { @@ -169,7 +188,7 @@ public static Result RefineSchema(TableSchema schema, ReadOnlySpan< ); } - return Results.Success(schema with { Columns = newColumns }); + return newColumns; } /// @@ -251,56 +270,72 @@ HashSet observedKeys continue; } - var propertyName = - reader.GetString() - ?? throw new UnreachableException( - "GetString() returned null on a PropertyName token." - ); - - if (!reader.Read()) - { - return Results.Failure("Unexpected end of JSON."); - } - - if (TypeInferrer.IsNullToken(reader.TokenType)) + var propertyResult = ScanProperty(ref reader, columnMap, keyOrder, observedKeys); + if (propertyResult.IsFailure) { - // JSON null: do NOT change type, do NOT add to observedKeys - if (!columnMap.ContainsKey(propertyName)) - { - columnMap[propertyName] = ColumnType.Text; - keyOrder.Add(propertyName); - } - - continue; + return propertyResult; } + } - var inferredType = TypeInferrer.InferType(reader.TokenType, reader.ValueSpan); - - if ( - reader.TokenType == JsonTokenType.StartObject - || reader.TokenType == JsonTokenType.StartArray - ) - { - reader.Skip(); - } + return Results.Success(); + } + catch (JsonException) + { + return Results.Failure("Malformed JSON line."); + } + } - observedKeys.Add(propertyName); + // Reads one "key": value pair (reader positioned at PropertyName) and updates the mutable + // maps in-place, resolving the column's type against any prior observation of the same key. + private static Result ScanProperty( + ref Utf8JsonReader reader, + Dictionary columnMap, + List keyOrder, + HashSet observedKeys) + { + var propertyName = + reader.GetString() + ?? throw new UnreachableException( + "GetString() returned null on a PropertyName token." + ); - if (!columnMap.TryGetValue(propertyName, out var existingType)) - { - columnMap[propertyName] = inferredType; - keyOrder.Add(propertyName); - continue; - } + if (!reader.Read()) + { + return Results.Failure("Unexpected end of JSON."); + } - columnMap[propertyName] = ColumnTypeResolver.Resolve(existingType, inferredType); + if (TypeInferrer.IsNullToken(reader.TokenType)) + { + // JSON null: do NOT change type, do NOT add to observedKeys + if (!columnMap.ContainsKey(propertyName)) + { + columnMap[propertyName] = ColumnType.Text; + keyOrder.Add(propertyName); } return Results.Success(); } - catch (JsonException) + + var inferredType = TypeInferrer.InferType(reader.TokenType, reader.ValueSpan); + + if ( + reader.TokenType == JsonTokenType.StartObject + || reader.TokenType == JsonTokenType.StartArray + ) { - return Results.Failure("Malformed JSON line."); + reader.Skip(); } + + observedKeys.Add(propertyName); + + if (!columnMap.TryGetValue(propertyName, out var existingType)) + { + columnMap[propertyName] = inferredType; + keyOrder.Add(propertyName); + return Results.Success(); + } + + columnMap[propertyName] = ColumnTypeResolver.Resolve(existingType, inferredType); + return Results.Success(); } } diff --git a/src/Engine/IO/JsonLines/TypeInferrer.cs b/src/Engine/IO/JsonLines/TypeInferrer.cs index da6d76d..3157836 100644 --- a/src/Engine/IO/JsonLines/TypeInferrer.cs +++ b/src/Engine/IO/JsonLines/TypeInferrer.cs @@ -29,63 +29,47 @@ public static class TypeInferrer /// Used when is or /// . /// - public static ColumnType InferType(JsonTokenType tokenType, ReadOnlySpan valueSpan) - { - if (tokenType == JsonTokenType.Number) + public static ColumnType InferType(JsonTokenType tokenType, ReadOnlySpan valueSpan) => + tokenType switch { - // Utf8Parser.TryParse operates directly on the UTF-8 byte span, - // avoiding string allocation. It is equivalent to what - // Utf8JsonReader.TryGetInt64 does internally, but does not require - // passing ref Utf8JsonReader and makes the absence of side effects explicit. - // bytesConsumed == valueSpan.Length guards against partial matches - // (e.g. "123abc"), which cannot occur for valid JSON tokens but is - // included for correctness. - if ( - Utf8Parser.TryParse(valueSpan, out long _, out var bytesConsumed) - && bytesConsumed == valueSpan.Length - ) - { - return ColumnType.WholeNumber; - } - - // Decimal point present (invariant: '.' only) → FloatingPoint. - // Numbers without '.' that overflow int64 (e.g. big integers) → Text. - if (valueSpan.Contains((byte)'.')) - { - return ColumnType.FloatingPoint; - } - - return ColumnType.Text; - } + JsonTokenType.Number => InferNumberType(valueSpan), + JsonTokenType.String => InferStringType(valueSpan), + JsonTokenType.True or JsonTokenType.False => ColumnType.Boolean, + JsonTokenType.StartObject => ColumnType.JsonObject, + JsonTokenType.StartArray => ColumnType.JsonArray, + _ => ColumnType.Text, + }; - if (tokenType == JsonTokenType.String) - { - if (TryParseTimestamp(valueSpan, out _)) - { - return ColumnType.Timestamp; - } - - return ColumnType.Text; - } - - if (tokenType == JsonTokenType.True || tokenType == JsonTokenType.False) - { - return ColumnType.Boolean; - } - - if (tokenType == JsonTokenType.StartObject) + private static ColumnType InferNumberType(ReadOnlySpan valueSpan) + { + // Utf8Parser.TryParse operates directly on the UTF-8 byte span, + // avoiding string allocation. It is equivalent to what + // Utf8JsonReader.TryGetInt64 does internally, but does not require + // passing ref Utf8JsonReader and makes the absence of side effects explicit. + // bytesConsumed == valueSpan.Length guards against partial matches + // (e.g. "123abc"), which cannot occur for valid JSON tokens but is + // included for correctness. + if ( + Utf8Parser.TryParse(valueSpan, out long _, out var bytesConsumed) + && bytesConsumed == valueSpan.Length + ) { - return ColumnType.JsonObject; + return ColumnType.WholeNumber; } - if (tokenType == JsonTokenType.StartArray) + // Decimal point present (invariant: '.' only) → FloatingPoint. + // Numbers without '.' that overflow int64 (e.g. big integers) → Text. + if (valueSpan.Contains((byte)'.')) { - return ColumnType.JsonArray; + return ColumnType.FloatingPoint; } return ColumnType.Text; } + private static ColumnType InferStringType(ReadOnlySpan valueSpan) => + TryParseTimestamp(valueSpan, out _) ? ColumnType.Timestamp : ColumnType.Text; + /// /// Returns true if the token type is . /// Mirrors the role of TypeInferrer.IsEmptyOrWhitespace() in the CSV pipeline. diff --git a/src/Engine/IO/JsonObject/TopLevelScanner.cs b/src/Engine/IO/JsonObject/TopLevelScanner.cs index 38fd460..889f7c5 100644 --- a/src/Engine/IO/JsonObject/TopLevelScanner.cs +++ b/src/Engine/IO/JsonObject/TopLevelScanner.cs @@ -125,6 +125,17 @@ Dictionary keyIndex return false; } + return ProcessDepthOneToken(ref reader, ref state, buffer, result, keyIndex); + } + + private static bool ProcessDepthOneToken( + ref Utf8JsonReader reader, + ref JsonScanState state, + byte[] buffer, + List result, + Dictionary keyIndex + ) + { if (reader.TokenType == JsonTokenType.PropertyName) { var key = diff --git a/src/Engine/Recipes/RecipeYamlParser.cs b/src/Engine/Recipes/RecipeYamlParser.cs index 32e534f..1e5c15c 100644 --- a/src/Engine/Recipes/RecipeYamlParser.cs +++ b/src/Engine/Recipes/RecipeYamlParser.cs @@ -31,50 +31,13 @@ public static Result Parse(string yaml) continue; } - if (rootState.ParseState == ParseState.Root) + var lineResult = ProcessLine(line, rootState, currentAction, actions); + if (lineResult.IsFailure) { - var result = ProcessRootLine(line, rootState); - if (result.IsFailure) - { - return Results.Failure(result.Error); - } - - rootState = result.Value; - continue; + return Results.Failure(lineResult.Error); } - if (line.StartsWith(" - type: ", StringComparison.Ordinal)) - { - var startResult = StartNewAction(line, currentAction); - if (startResult.IsFailure) - { - return Results.Failure(startResult.Error); - } - - var (newCurrentAction, completedAction) = startResult.Value; - currentAction = newCurrentAction; - rootState = rootState with { ParseState = ParseState.ActionItem }; - if (completedAction is not null) - { - actions.Add(completedAction); - } - - continue; - } - - if (rootState.ParseState != ParseState.ActionItem || !line.StartsWith(" ", StringComparison.Ordinal)) - { - return Results.Failure($"Unexpected line in actions context: '{line}'"); - } - - var fieldResult = ParseActionField(line); - if (fieldResult.IsFailure) - { - return Results.Failure(fieldResult.Error); - } - - var (fieldKey, fieldValue) = fieldResult.Value; - currentAction[fieldKey] = fieldValue; + (rootState, currentAction) = lineResult.Value; } if (currentAction.ContainsKey("type")) @@ -99,6 +62,57 @@ public static Result Parse(string yaml) }); } + // Dispatches a single YAML line to the handler for the current parse state. + // currentAction is returned rather than mutated-in-place because StartNewAction + // replaces it wholesale with a fresh dictionary for the next action item. + private static Result<(RootParseState rootState, Dictionary currentAction)> ProcessLine( + string line, + RootParseState rootState, + Dictionary currentAction, + List actions) + { + if (rootState.ParseState == ParseState.Root) + { + var result = ProcessRootLine(line, rootState); + return result.IsFailure + ? Results.Failure<(RootParseState rootState, Dictionary currentAction)>(result.Error) + : Results.Success((result.Value, currentAction)); + } + + if (line.StartsWith(" - type: ", StringComparison.Ordinal)) + { + var startResult = StartNewAction(line, currentAction); + if (startResult.IsFailure) + { + return Results.Failure<(RootParseState rootState, Dictionary currentAction)>(startResult.Error); + } + + var (newCurrentAction, completedAction) = startResult.Value; + if (completedAction is not null) + { + actions.Add(completedAction); + } + + return Results.Success((rootState with { ParseState = ParseState.ActionItem }, newCurrentAction)); + } + + if (rootState.ParseState != ParseState.ActionItem || !line.StartsWith(" ", StringComparison.Ordinal)) + { + return Results.Failure<(RootParseState rootState, Dictionary currentAction)>( + $"Unexpected line in actions context: '{line}'"); + } + + var fieldResult = ParseActionField(line); + if (fieldResult.IsFailure) + { + return Results.Failure<(RootParseState rootState, Dictionary currentAction)>(fieldResult.Error); + } + + var (fieldKey, fieldValue) = fieldResult.Value; + currentAction[fieldKey] = fieldValue; + return Results.Success((rootState, currentAction)); + } + private static bool IsSkippable(string line) => string.IsNullOrWhiteSpace(line) || line.AsSpan().TrimStart().StartsWith("#", StringComparison.Ordinal); @@ -125,19 +139,28 @@ private static Result ProcessRootLine(string line, RootParseStat return key switch { - "name" => !string.IsNullOrEmpty(state.Name) - ? Results.Failure("Duplicate root-level key: 'name'") - : Results.Success(state with { Name = UnquoteString(value) }), - "description" => state.Description is not null - ? Results.Failure("Duplicate root-level key: 'description'") - : Results.Success(state with { Description = UnquoteString(value) }), - "lastModified" => state.LastModified is not null - ? Results.Failure("Duplicate root-level key: 'lastModified'") - : ParseLastModifiedField(value, state), + "name" => SetName(state, value), + "description" => SetDescription(state, value), + "lastModified" => SetLastModified(state, value), _ => Results.Failure($"Unknown root-level key: '{key}'"), }; } + private static Result SetName(RootParseState state, string value) => + !string.IsNullOrEmpty(state.Name) + ? Results.Failure("Duplicate root-level key: 'name'") + : Results.Success(state with { Name = UnquoteString(value) }); + + private static Result SetDescription(RootParseState state, string value) => + state.Description is not null + ? Results.Failure("Duplicate root-level key: 'description'") + : Results.Success(state with { Description = UnquoteString(value) }); + + private static Result SetLastModified(RootParseState state, string value) => + state.LastModified is not null + ? Results.Failure("Duplicate root-level key: 'lastModified'") + : ParseLastModifiedField(value, state); + private static Result ParseLastModifiedField(string value, RootParseState state) { var parseResult = TryParseLastModified(value); @@ -193,35 +216,35 @@ private static Result TryParseLastModified(string value) private static string UnquoteString(string value) { - if (value.Length >= 2 && value[0] == '"' && value[^1] == '"') + if (value.Length < 2 || value[0] != '"' || value[^1] != '"') { - var inner = value.AsSpan(1, value.Length - 2); - if (inner.IndexOf('\\') < 0) - { - return inner.ToString(); - } + return value; + } + + var inner = value.AsSpan(1, value.Length - 2); + return inner.IndexOf('\\') < 0 ? inner.ToString() : UnescapeString(inner); + } - var sb = new StringBuilder(inner.Length); - for (var i = 0; i < inner.Length; i++) + private static string UnescapeString(ReadOnlySpan inner) + { + var sb = new StringBuilder(inner.Length); + for (var i = 0; i < inner.Length; i++) + { + if (inner[i] == '\\' && i + 1 < inner.Length) { - if (inner[i] == '\\' && i + 1 < inner.Length) + sb.Append(inner[i + 1] switch { - sb.Append(inner[i + 1] switch - { - '"' => '"', - '\\' => '\\', - var c => c, - }); - i++; - continue; - } - - sb.Append(inner[i]); + '"' => '"', + '\\' => '\\', + var c => c, + }); + i++; + continue; } - return sb.ToString(); + sb.Append(inner[i]); } - return value; + return sb.ToString(); } }