From a4385a08c40d964a9710395766dfa0c74f97bcee Mon Sep 17 00:00:00 2001 From: cangelosilima Date: Sat, 26 Sep 2026 11:50:09 -0300 Subject: [PATCH 1/2] Add interactive SQL parser dashboard command --- cli/docs/cli.md | 41 ++++ cli/src/SyncSql.Cli/Commands/ParserCommand.cs | 43 ++++ .../SyncSql.Cli/Parsing/ParserDashboard.cs | 185 ++++++++++++++++++ cli/src/SyncSql.Cli/Parsing/SqlInspection.cs | 144 ++++++++++++++ cli/src/SyncSql.Cli/Program.cs | 1 + .../SyncSql.Cli.Tests/ParserCommandTests.cs | 128 ++++++++++++ 6 files changed, 542 insertions(+) create mode 100644 cli/src/SyncSql.Cli/Commands/ParserCommand.cs create mode 100644 cli/src/SyncSql.Cli/Parsing/ParserDashboard.cs create mode 100644 cli/src/SyncSql.Cli/Parsing/SqlInspection.cs create mode 100644 cli/tests/SyncSql.Cli.Tests/ParserCommandTests.cs diff --git a/cli/docs/cli.md b/cli/docs/cli.md index 7f354b3..6415d23 100644 --- a/cli/docs/cli.md +++ b/cli/docs/cli.md @@ -278,6 +278,47 @@ Findings are logged one per line as `path:line:column [rule-id] message`, at Exit code `0` if nothing at or above `--fail-on` was found; `1` otherwise (or if a given `--path` doesn't exist). +### `syncsql parser` + +Open a SQL file in a read-only, keyboard-driven terminal dashboard: + +```bash +syncsql parser --file ./procedure.sql +syncsql parser --file ./package.sql --engine oracle +syncsql parser --file ./procedure.sql --plain +# From the repository, without installing: +dotnet run --project cli/src/SyncSql.Cli -- parser --file ./procedure.sql +``` + +SQL Server is the default dialect. Use `--engine oracle` for PL/SQL; the +command uses the existing ScriptDom and ANTLR grammars and lineage analyzers. +No database connection or server configuration is required. + +The dashboard has seven views: syntax tree, tokens (including comments and +whitespace), referenced objects, alias bindings, column references, +diagnostics, and full source. Select a syntax node or token to see its SQL +and line/column location in the right pane. Objects include static/dynamic +origin information. References are inferred from the script, without catalog +resolution; syntax errors leave a partial tree available for inspection. + +| Key | Action | +| --- | --- | +| Tab / Shift+Tab, Left / Right | Switch views | +| 1–7 | Jump to a view | +| Up / Down or k / j | Select a piece | +| Home / End | Select first / last piece | +| /, then Enter | Filter piece names and descriptions | +| Esc while filtering | Clear filter | +| PageUp / PageDown | Scroll the detail/source pane | +| h / l | Pan the detail pane horizontally | +| q / Esc / Ctrl+C | Exit | + +Use a terminal of at least 70 columns by 12 rows. Resizing redraws the +dashboard. `--plain`, redirected input/output, or `TERM=dumb` prints all views +as text without interactive controls. Exit code is 1 for parse errors or an +unreadable file and 0 for a clean parse, including an empty file. The input +file is never modified. + ## Configuration `syncsql` reads the exact same `config/servers.json` schema as the diff --git a/cli/src/SyncSql.Cli/Commands/ParserCommand.cs b/cli/src/SyncSql.Cli/Commands/ParserCommand.cs new file mode 100644 index 0000000..dd35787 --- /dev/null +++ b/cli/src/SyncSql.Cli/Commands/ParserCommand.cs @@ -0,0 +1,43 @@ +using System.CommandLine; +using SyncSql.Cli.Parsing; + +namespace SyncSql.Cli.Commands; + +internal static class ParserCommand +{ + public static Command Build() + { + Option file = new("--file") { Required = true, Description = "SQL file to inspect (read only)." }; + Option engine = new("--engine") { Description = "SQL dialect: mssql (default) or oracle." }; + engine.AcceptOnlyFromAmong("mssql", "oracle"); + Option plain = new("--plain") { Description = "Print all pieces as text instead of opening the dashboard." }; + Command command = new("parser", "Explore a SQL file's syntax tree, tokens, lineage, and diagnostics.") { file, engine, plain }; + command.SetAction(async (result, cancellationToken) => + { + try + { + string path = Path.GetFullPath(result.GetValue(file)!); + string sql = await File.ReadAllTextAsync(path, cancellationToken); + string dialect = result.GetValue(engine) ?? "mssql"; + SqlInspection inspection = SqlInspection.Parse(path, sql, dialect); + if (result.GetValue(plain) || Console.IsInputRedirected || Console.IsOutputRedirected || + string.Equals(Environment.GetEnvironmentVariable("TERM"), "dumb", StringComparison.Ordinal)) + { + ParserDashboard.Print(inspection, Console.Out); + } + else + { + ParserDashboard.Run(inspection, cancellationToken); + } + + return inspection.HasErrors ? 1 : 0; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + Console.Error.WriteLine($"Cannot inspect SQL file: {ParserDashboard.Safe(exception.Message)}"); + return 1; + } + }); + return command; + } +} diff --git a/cli/src/SyncSql.Cli/Parsing/ParserDashboard.cs b/cli/src/SyncSql.Cli/Parsing/ParserDashboard.cs new file mode 100644 index 0000000..aadb6ed --- /dev/null +++ b/cli/src/SyncSql.Cli/Parsing/ParserDashboard.cs @@ -0,0 +1,185 @@ +using System.Text; + +namespace SyncSql.Cli.Parsing; + +internal static class ParserDashboard +{ + // Never allow SQL text, filenames or diagnostics to inject terminal control sequences. + internal static string Safe(string value) => new(value.Select(c => char.IsControl(c) ? ' ' : c).ToArray()); + + public static void Print(SqlInspection inspection, TextWriter output) + { + output.WriteLine($"{Safe(inspection.Path)} ({inspection.Engine})"); + foreach (SqlSection section in inspection.Sections) + { + output.WriteLine($"\n[{section.Name}] {section.Pieces.Count}"); + foreach (SqlPiece piece in section.Pieces) + { + foreach (string line in inspection.Describe(piece).Split('\n')) + { + output.WriteLine(Safe(line)); + } + } + } + } + + public static void Run(SqlInspection inspection, CancellationToken cancellationToken) + { + DashboardState state = new(inspection); + bool originalControlC = Console.TreatControlCAsInput; + try + { + Console.TreatControlCAsInput = true; + Console.Write("\u001b[?1049h\u001b[?25l"); + int lastWidth = 0; + int lastHeight = 0; + bool redraw = true; + while (!cancellationToken.IsCancellationRequested) + { + int width = Math.Max(1, Console.WindowWidth); + int height = Math.Max(1, Console.WindowHeight); + if (redraw || width != lastWidth || height != lastHeight) + { + Console.Write(Render(state, width, height)); + lastWidth = width; + lastHeight = height; + redraw = false; + } + if (Console.KeyAvailable) + { + if (!state.Handle(Console.ReadKey(intercept: true), Math.Max(1, height - 7))) { break; } + redraw = true; + } + else + { + cancellationToken.WaitHandle.WaitOne(80); + } + } + } + finally + { + Console.Write("\u001b[0m\u001b[?25h\u001b[?1049l"); + Console.TreatControlCAsInput = originalControlC; + } + } + + internal static string Render(DashboardState state, int width, int height) + { + // Leave the final terminal column unused to avoid automatic line wrapping. + int columns = Math.Max(1, width - 1); + List lines = []; + if (width < 70 || height < 12) + { + lines.Add("Resize terminal to at least 70 x 12. Press q to quit."); + } + else + { + SqlInspection inspection = state.Inspection; + lines.Add($" SYNCSQL / PARSER {inspection.Engine.ToUpperInvariant()} {(inspection.HasErrors ? "PARSE ERRORS - partial tree" : "PARSED")}"); + lines.Add(" " + inspection.Path); + lines.Add(" " + string.Join(" ", inspection.Sections.Select((s, i) => + $"{(i == state.Section ? ">" : "")}{i + 1}:{s.Name.Replace("Syntax tree", "Tree", StringComparison.Ordinal).Replace("Diagnostics", "Errors", StringComparison.Ordinal)}"))); + int left = Math.Clamp(columns / 3, 24, 48); + int right = columns - left - 3; + lines.Add(Fit($" {inspection.Sections[state.Section].Name} / {state.Items.Count} pieces", left) + " | " + "DETAIL / SQL"); + int rows = height - 7; + int start = state.Selected / rows * rows; + string[] details = state.Items.Count == 0 ? ["No pieces match this view."] : + inspection.Describe(state.Items[state.Selected]).Replace("\r", "", StringComparison.Ordinal).Split('\n'); + state.DetailRow = Math.Clamp(state.DetailRow, 0, Math.Max(0, details.Length - rows)); + for (int row = 0; row < rows; row++) + { + int index = start + row; + string label = index < state.Items.Count ? + $"{(index == state.Selected ? ">" : " ")} {state.Items[index].Name}" : ""; + int detailIndex = state.DetailRow + row; + string detail = detailIndex < details.Length ? details[detailIndex] : ""; + detail = detail[Math.Min(state.DetailColumn, detail.Length)..]; + string line = Fit(label, left) + " | " + Fit(detail, right); + lines.Add(line); + } + lines.Add($" / Filter: {state.Filter}{(state.EditingFilter ? "_ (Enter to apply, Esc to clear)" : "")} | detail line {state.DetailRow + 1}/{details.Length}"); + lines.Add(" Tab/Left/Right: view Up/Down/j/k: select 1-7: view /: filter"); + lines.Add(" PgUp/PgDn: scroll detail h/l: pan detail Home/End: first/last q/Esc: quit"); + } + StringBuilder frame = new("\u001b[H"); + for (int row = 0; row < height; row++) + { + if (row > 0) { frame.Append("\r\n"); } + if (row < 4) { frame.Append("\u001b[36m"); } + frame.Append(Fit(row < lines.Count ? lines[row] : "", columns)); + frame.Append("\u001b[0m"); + } + return frame.ToString(); + } + + private static string Fit(string text, int width) + { + string safe = Safe(text); + return safe.Length > width ? safe[..width] : safe.PadRight(width); + } +} + +internal sealed class DashboardState(SqlInspection inspection) +{ + public SqlInspection Inspection { get; } = inspection; + public int Section { get; private set; } + public int Selected { get; private set; } + public int DetailRow { get; set; } + public int DetailColumn { get; private set; } + public string Filter { get; private set; } = ""; + public bool EditingFilter { get; private set; } + public IReadOnlyList Items { get; private set; } = inspection.Sections[0].Pieces; + + public bool Handle(ConsoleKeyInfo key, int pageSize) + { + if (key.Key == ConsoleKey.C && key.Modifiers.HasFlag(ConsoleModifiers.Control)) { return false; } + if (EditingFilter) + { + if (key.Key == ConsoleKey.Enter) { EditingFilter = false; } + else if (key.Key == ConsoleKey.Escape) { Filter = ""; EditingFilter = false; } + else if (key.Key == ConsoleKey.Backspace) { Filter = Filter[..Math.Max(0, Filter.Length - 1)]; } + else if (!char.IsControl(key.KeyChar)) { Filter += key.KeyChar; } + Refresh(); + return true; + } + if (key.Key is ConsoleKey.Q or ConsoleKey.Escape) { return false; } + if (key.KeyChar == '/') { EditingFilter = true; return true; } + int section = Section; + if (key.Key == ConsoleKey.Tab) { section += key.Modifiers.HasFlag(ConsoleModifiers.Shift) ? -1 : 1; } + else if (key.Key == ConsoleKey.RightArrow) { section++; } + else if (key.Key == ConsoleKey.LeftArrow) { section--; } + else if (key.KeyChar is >= '1' and <= '7') { section = key.KeyChar - '1'; } + if (section != Section) + { + Section = (section + Inspection.Sections.Count) % Inspection.Sections.Count; + Filter = ""; + Refresh(); + return true; + } + int selected = Selected; + switch (key.Key) + { + case ConsoleKey.DownArrow: case ConsoleKey.J: selected++; break; + case ConsoleKey.UpArrow: case ConsoleKey.K: selected--; break; + case ConsoleKey.Home: selected = 0; break; + case ConsoleKey.End: selected = Items.Count - 1; break; + case ConsoleKey.PageDown: DetailRow += pageSize; break; + case ConsoleKey.PageUp: DetailRow = Math.Max(0, DetailRow - pageSize); break; + case ConsoleKey.H: DetailColumn = Math.Max(0, DetailColumn - 8); break; + case ConsoleKey.L: DetailColumn += 8; break; + } + selected = Math.Clamp(selected, 0, Math.Max(0, Items.Count - 1)); + if (selected != Selected) { Selected = selected; DetailRow = 0; DetailColumn = 0; } + return true; + } + + private void Refresh() + { + Items = Inspection.Sections[Section].Pieces.Where(p => p.Name.Contains(Filter, StringComparison.OrdinalIgnoreCase) || + (p.Description?.Contains(Filter, StringComparison.OrdinalIgnoreCase) ?? false)).ToArray(); + Selected = 0; + DetailRow = 0; + DetailColumn = 0; + } +} diff --git a/cli/src/SyncSql.Cli/Parsing/SqlInspection.cs b/cli/src/SyncSql.Cli/Parsing/SqlInspection.cs new file mode 100644 index 0000000..5ba2bf4 --- /dev/null +++ b/cli/src/SyncSql.Cli/Parsing/SqlInspection.cs @@ -0,0 +1,144 @@ +using System.Collections; +using System.Globalization; +using Antlr4.Runtime; +using Antlr4.Runtime.Tree; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.SqlServer.TransactSql.ScriptDom; +using SyncSql.Core.Domain; +using SyncSql.Lineage.MsSql; +using SyncSql.Lineage.Oracle; + +namespace SyncSql.Cli.Parsing; + +internal sealed record SqlPiece(string Name, int Offset = 0, int Length = 0, int Line = 0, int Column = 0, string? Description = null); +internal sealed record SqlSection(string Name, IReadOnlyList Pieces); + +internal sealed record SqlInspection(string Path, string Sql, string Engine, IReadOnlyList Sections, bool HasErrors) +{ + public string Describe(SqlPiece piece) + { + string location = piece.Line > 0 ? string.Create(CultureInfo.InvariantCulture, $"Line {piece.Line}, column {piece.Column}\n") : ""; + int start = Math.Clamp(piece.Offset, 0, Sql.Length); + int length = Math.Clamp(piece.Length, 0, Sql.Length - start); + return $"{piece.Name}\n{location}{piece.Description}\n{Sql.Substring(start, length)}"; + } + + public static SqlInspection Parse(string path, string sql, string engine) + { + List nodes = []; + List tokens = []; + List diagnostics = []; + LineageAnalysisResult lineage; + if (engine == "oracle") + { + PlSqlLexer lexer = new(new AntlrInputStream(sql)); + OracleErrors errors = new(diagnostics); + lexer.RemoveErrorListeners(); + lexer.AddErrorListener(errors); + CommonTokenStream stream = new(lexer); + PlSqlParser parser = new(stream); + parser.RemoveErrorListeners(); + parser.AddErrorListener(errors); + IParseTree root = parser.sql_script(); + Stack<(IParseTree Tree, int Depth)> pending = new(); + pending.Push((root, 0)); + while (pending.TryPop(out var entry)) + { + if (entry.Tree is ParserRuleContext context) + { + int offset = context.Start?.StartIndex ?? 0; + int end = context.Stop?.StopIndex + 1 ?? offset; + nodes.Add(new SqlPiece(new string(' ', Math.Min(entry.Depth, 24) * 2) + parser.RuleNames[context.RuleIndex], + offset, Math.Max(0, end - offset), context.Start?.Line ?? 0, (context.Start?.Column ?? 0) + 1)); + } + for (int i = entry.Tree.ChildCount - 1; i >= 0; i--) + { + pending.Push((entry.Tree.GetChild(i), entry.Depth + 1)); + } + } + stream.Fill(); + foreach (IToken token in stream.GetTokens()) + { + tokens.Add(new SqlPiece(parser.Vocabulary.GetSymbolicName(token.Type) ?? "EOF", token.StartIndex, + Math.Max(0, token.StopIndex - token.StartIndex + 1), token.Line, token.Column + 1, + $"Channel: {token.Channel}")); + } + lineage = new OracleLineageAnalyzer(NullLogger.Instance).Analyze(sql); + } + else + { + using StringReader reader = new(sql); + TSqlFragment root = TSqlParserFactory.GetParser().Parse(reader, out IList errors); + if (errors.Count > 0) + { + using StringReader legacyReader = new(sql); + TSqlFragment legacy = new TSql80Parser(true).Parse(legacyReader, out IList legacyErrors); + if (legacyErrors.Count == 0) + { + root = legacy; + errors = legacyErrors; + } + } + Stack<(TSqlFragment Node, int Depth)> pending = new(); + HashSet seen = new(ReferenceEqualityComparer.Instance); + pending.Push((root, 0)); + while (pending.TryPop(out var entry)) + { + if (!seen.Add(entry.Node)) { continue; } + TSqlFragment node = entry.Node; + nodes.Add(new SqlPiece(new string(' ', Math.Min(entry.Depth, 24) * 2) + node.GetType().Name, + node.StartOffset, node.FragmentLength, node.StartLine, node.StartColumn)); + List children = []; + foreach (var property in node.GetType().GetProperties()) + { + if (property.GetIndexParameters().Length != 0) { continue; } + if (typeof(TSqlFragment).IsAssignableFrom(property.PropertyType)) + { + if (property.GetValue(node) is TSqlFragment child) { children.Add(child); } + } + else if (property.PropertyType.IsGenericType && + property.PropertyType.GetGenericArguments().Any(t => typeof(TSqlFragment).IsAssignableFrom(t)) && + property.GetValue(node) is IEnumerable collection) + { + children.AddRange(collection.OfType()); + } + } + foreach (TSqlFragment child in children.OrderByDescending(c => c.StartOffset)) + { + pending.Push((child, entry.Depth + 1)); + } + } + foreach (TSqlParserToken token in root.ScriptTokenStream ?? []) + { + tokens.Add(new SqlPiece(token.TokenType.ToString(), token.Offset, token.Text?.Length ?? 0, token.Line, token.Column)); + } + diagnostics.AddRange(errors.Select(e => new SqlPiece($"SQL{e.Number}", e.Offset, 0, e.Line, e.Column, e.Message))); + lineage = new MsSqlLineageAnalyzer(NullLogger.Instance).Analyze(sql); + } + + return new SqlInspection(path, sql, engine, + [ + new("Syntax tree", nodes), + new("Tokens", tokens), + new("Objects", lineage.ObjectRefs.Select(o => new SqlPiece(ObjectName(o), Description: $"Origin: {o.Origin}\nType: {o.ObjectType ?? "unspecified"}\nRoutine: {o.IsRoutine}")).ToArray()), + new("Aliases", lineage.Aliases.Select(a => new SqlPiece(a.Key, Description: ObjectName(a.Value))).ToArray()), + new("Columns", lineage.ColumnRefs.Select(c => new SqlPiece($"{c.AliasOrTable}.{c.Column}")).ToArray()), + new("Diagnostics", diagnostics), + new("Source", [new SqlPiece(System.IO.Path.GetFileName(path), 0, sql.Length, 1, 1)]), + ], diagnostics.Count > 0); + } + + private static string ObjectName(ObjectRef reference) => string.Join('.', + new[] { reference.Server, reference.Database, reference.Schema, reference.Name }.Where(p => !string.IsNullOrEmpty(p))); + + private sealed class OracleErrors(List diagnostics) : BaseErrorListener, IAntlrErrorListener + { + public override void SyntaxError(TextWriter output, IRecognizer recognizer, IToken offendingSymbol, int line, + int charPositionInLine, string msg, RecognitionException e) => + diagnostics.Add(new SqlPiece("Parser error", offendingSymbol?.StartIndex ?? 0, 0, line, charPositionInLine + 1, msg)); + + public void SyntaxError(TextWriter output, IRecognizer recognizer, int offendingSymbol, int line, + int charPositionInLine, string msg, RecognitionException e) => + diagnostics.Add(new SqlPiece("Lexer error", Line: line, Column: charPositionInLine + 1, Description: msg)); + } +} diff --git a/cli/src/SyncSql.Cli/Program.cs b/cli/src/SyncSql.Cli/Program.cs index 732944c..c39ea84 100644 --- a/cli/src/SyncSql.Cli/Program.cs +++ b/cli/src/SyncSql.Cli/Program.cs @@ -28,6 +28,7 @@ CatalogCommand.Build(host.Services), MetricsCommand.Build(host.Services), LintCommand.Build(host.Services), + ParserCommand.Build(), }; return await rootCommand.Parse(args).InvokeAsync(); diff --git a/cli/tests/SyncSql.Cli.Tests/ParserCommandTests.cs b/cli/tests/SyncSql.Cli.Tests/ParserCommandTests.cs new file mode 100644 index 0000000..28cc408 --- /dev/null +++ b/cli/tests/SyncSql.Cli.Tests/ParserCommandTests.cs @@ -0,0 +1,128 @@ +using System.CommandLine; +using System.Globalization; +using SyncSql.Cli.Commands; +using SyncSql.Cli.Parsing; + +namespace SyncSql.Cli.Tests; + +public sealed class ParserCommandTests +{ + [Theory] + [InlineData("mssql", "SELECT o.Id FROM dbo.Orders AS o WHERE o.Id = 1;", "SelectStatement")] + [InlineData("oracle", "SELECT o.Id FROM app.Orders o WHERE o.Id = 1;", "select_statement")] + public void InspectionIncludesTreeTokensAndLineage(string engine, string sql, string statement) + { + SqlInspection result = SqlInspection.Parse("example.sql", sql, engine); + Assert.False(result.HasErrors); + Assert.Contains(result.Sections[0].Pieces, p => p.Name.Trim() == statement); + Assert.Contains(result.Sections[1].Pieces, p => result.Describe(p).Contains("Orders", StringComparison.Ordinal)); + Assert.Contains(result.Sections[2].Pieces, p => p.Name.EndsWith("Orders", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Sections[3].Pieces, p => p.Name.Equals("o", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Sections[4].Pieces, p => p.Name.Equals("o.Id", StringComparison.OrdinalIgnoreCase)); + Assert.All(result.Sections[0].Pieces, p => Assert.InRange(p.Offset, 0, sql.Length)); + Assert.Equal(sql, result.Sql); + } + + [Theory] + [InlineData("mssql")] + [InlineData("oracle")] + public void InvalidSqlKeepsDiagnosticsAndOriginalSource(string engine) + { + SqlInspection result = SqlInspection.Parse("broken.sql", "SELECT FROM ;", engine); + Assert.True(result.HasErrors); + Assert.NotEmpty(result.Sections[5].Pieces); + Assert.All(result.Sections[5].Pieces, p => Assert.True(p.Line > 0 && p.Column > 0)); + Assert.Contains("SELECT FROM ;", result.Describe(result.Sections[6].Pieces[0])); + } + + [Fact] + public void BatchesCommentsStringsAndLocationsArePreserved() + { + const string sql = "-- comment\nSELECT 'GO; not a statement';\nGO\nSELECT 2;"; + SqlInspection result = SqlInspection.Parse("batch.sql", sql, "mssql"); + Assert.False(result.HasErrors); + Assert.Equal(2, result.Sections[0].Pieces.Count(p => p.Name.Trim() == "SelectStatement")); + Assert.Contains(result.Sections[1].Pieces, p => p.Name == "SingleLineComment"); + Assert.Contains(result.Sections[0].Pieces, p => p.Name.Trim() == "SelectStatement" && p.Line == 4); + } + + [Theory] + [InlineData("mssql")] + [InlineData("oracle")] + public void EmptyFilesAreInspectable(string engine) + { + SqlInspection result = SqlInspection.Parse("empty.sql", "", engine); + Assert.False(result.HasErrors); + Assert.Empty(result.Sections[2].Pieces); + Assert.Contains("empty.sql", ParserDashboard.Render(new DashboardState(result), 100, 24)); + } + + [Fact] + public void NavigationFilteringScrollingAndQuitWork() + { + DashboardState state = new(SqlInspection.Parse("query.sql", "SELECT o.Id FROM dbo.Orders o;", "mssql")); + Assert.True(state.Handle(Key(ConsoleKey.End), 10)); + Assert.Equal(state.Items.Count - 1, state.Selected); + state.Handle(Key(ConsoleKey.Tab), 10); + Assert.Equal(1, state.Section); + Assert.Equal(0, state.Selected); + state.Handle(new ConsoleKeyInfo('/', ConsoleKey.Oem2, false, false, false), 10); + foreach (char c in "Identifier") { state.Handle(new ConsoleKeyInfo(c, ConsoleKey.A, false, false, false), 10); } + state.Handle(Key(ConsoleKey.Enter), 10); + Assert.NotEmpty(state.Items); + Assert.All(state.Items, p => Assert.Contains("Identifier", p.Name)); + state.Handle(Key(ConsoleKey.PageDown), 10); + Assert.Equal(10, state.DetailRow); + state.Handle(Key(ConsoleKey.L), 10); + Assert.Equal(8, state.DetailColumn); + state.Handle(Key(ConsoleKey.LeftArrow), 10); + Assert.Equal("", state.Filter); + Assert.Equal(0, state.DetailRow); + Assert.False(state.Handle(Key(ConsoleKey.Q), 10)); + } + + [Fact] + public void RenderingHandlesSmallWindowsEmptyFiltersAndControlCharacters() + { + SqlInspection inspection = SqlInspection.Parse("unsafe\u001b[2J.sql", "SELECT '\u001b[2J';", "mssql"); + DashboardState state = new(inspection); + Assert.Contains("Resize terminal", ParserDashboard.Render(state, 60, 10)); + state.Handle(new ConsoleKeyInfo('/', ConsoleKey.Oem2, false, false, false), 10); + state.Handle(new ConsoleKeyInfo('~', ConsoleKey.Oem3, false, false, false), 10); + string frame = ParserDashboard.Render(state, 100, 24); + Assert.Contains("No pieces match", frame); + Assert.DoesNotContain("\u001b[2J", frame, StringComparison.Ordinal); + Assert.Equal(24, frame.Split("\r\n", StringSplitOptions.None).Length); + using StringWriter output = new(CultureInfo.InvariantCulture); + ParserDashboard.Print(inspection, output); + Assert.DoesNotContain('\u001b', output.ToString()); + Assert.Contains("[Tokens]", output.ToString()); + } + + [Theory] + [InlineData("", 0)] + [InlineData("SELECT 1;", 0)] + [InlineData("SELECT FROM ;", 1)] + public async Task PlainCommandReturnsParseStatus(string sql, int expected) + { + string file = Path.GetTempFileName(); + try + { + await File.WriteAllTextAsync(file, sql); + Assert.Equal(expected, await new RootCommand { ParserCommand.Build() } + .Parse(["parser", "--file", file, "--plain"]).InvokeAsync()); + } + finally { File.Delete(file); } + } + + [Fact] + public async Task CommandRejectsMissingFilesAndInvalidArguments() + { + RootCommand root = new() { ParserCommand.Build() }; + Assert.NotEmpty(root.Parse("parser").Errors); + Assert.NotEmpty(root.Parse("parser --file test.sql --engine postgres").Errors); + Assert.Equal(1, await root.Parse(["parser", "--file", Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".sql"), "--plain"]).InvokeAsync()); + } + + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); +} From 85fbb5f4549a276e6879a06fc478fec7ddbe4b6a Mon Sep 17 00:00:00 2001 From: cangelosilima Date: Sat, 26 Sep 2026 12:50:52 -0300 Subject: [PATCH 2/2] Cover parser dashboard lifecycle and terminal failure paths --- cli/src/SyncSql.Cli/Commands/ParserCommand.cs | 15 +- .../SyncSql.Cli/Parsing/ParserDashboard.cs | 24 +-- cli/src/SyncSql.Cli/Parsing/ParserTerminal.cs | 21 +++ cli/src/SyncSql.Cli/Parsing/SqlInspection.cs | 11 +- .../SyncSql.Cli.Tests/ParserCommandTests.cs | 28 ++++ .../ParserDashboardLifecycleTests.cs | 148 ++++++++++++++++++ .../ParserDashboardNavigationTests.cs | 92 +++++++++++ 7 files changed, 316 insertions(+), 23 deletions(-) create mode 100644 cli/src/SyncSql.Cli/Parsing/ParserTerminal.cs create mode 100644 cli/tests/SyncSql.Cli.Tests/ParserDashboardLifecycleTests.cs create mode 100644 cli/tests/SyncSql.Cli.Tests/ParserDashboardNavigationTests.cs diff --git a/cli/src/SyncSql.Cli/Commands/ParserCommand.cs b/cli/src/SyncSql.Cli/Commands/ParserCommand.cs index dd35787..2eeb1d4 100644 --- a/cli/src/SyncSql.Cli/Commands/ParserCommand.cs +++ b/cli/src/SyncSql.Cli/Commands/ParserCommand.cs @@ -5,8 +5,9 @@ namespace SyncSql.Cli.Commands; internal static class ParserCommand { - public static Command Build() + public static Command Build(ParserTerminal? terminal = null) { + terminal ??= new ParserTerminal(); Option file = new("--file") { Required = true, Description = "SQL file to inspect (read only)." }; Option engine = new("--engine") { Description = "SQL dialect: mssql (default) or oracle." }; engine.AcceptOnlyFromAmong("mssql", "oracle"); @@ -20,24 +21,26 @@ public static Command Build() string sql = await File.ReadAllTextAsync(path, cancellationToken); string dialect = result.GetValue(engine) ?? "mssql"; SqlInspection inspection = SqlInspection.Parse(path, sql, dialect); - if (result.GetValue(plain) || Console.IsInputRedirected || Console.IsOutputRedirected || - string.Equals(Environment.GetEnvironmentVariable("TERM"), "dumb", StringComparison.Ordinal)) + if (UsePlainOutput(result.GetValue(plain), terminal.InputRedirected, terminal.OutputRedirected, terminal.TerminalType)) { - ParserDashboard.Print(inspection, Console.Out); + ParserDashboard.Print(inspection, terminal.Output); } else { - ParserDashboard.Run(inspection, cancellationToken); + ParserDashboard.Run(inspection, terminal, cancellationToken); } return inspection.HasErrors ? 1 : 0; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { - Console.Error.WriteLine($"Cannot inspect SQL file: {ParserDashboard.Safe(exception.Message)}"); + terminal.Error.WriteLine($"Cannot inspect SQL file: {ParserDashboard.Safe(exception.Message)}"); return 1; } }); return command; } + + internal static bool UsePlainOutput(bool requested, bool inputRedirected, bool outputRedirected, string? terminalType) => + requested || inputRedirected || outputRedirected || string.Equals(terminalType, "dumb", StringComparison.Ordinal); } diff --git a/cli/src/SyncSql.Cli/Parsing/ParserDashboard.cs b/cli/src/SyncSql.Cli/Parsing/ParserDashboard.cs index aadb6ed..e14a9d8 100644 --- a/cli/src/SyncSql.Cli/Parsing/ParserDashboard.cs +++ b/cli/src/SyncSql.Cli/Parsing/ParserDashboard.cs @@ -23,43 +23,43 @@ public static void Print(SqlInspection inspection, TextWriter output) } } - public static void Run(SqlInspection inspection, CancellationToken cancellationToken) + public static void Run(SqlInspection inspection, ParserTerminal terminal, CancellationToken cancellationToken) { DashboardState state = new(inspection); - bool originalControlC = Console.TreatControlCAsInput; + bool originalControlC = terminal.ControlCAsInput; try { - Console.TreatControlCAsInput = true; - Console.Write("\u001b[?1049h\u001b[?25l"); + terminal.ControlCAsInput = true; + terminal.Output.Write("\u001b[?1049h\u001b[?25l"); int lastWidth = 0; int lastHeight = 0; bool redraw = true; while (!cancellationToken.IsCancellationRequested) { - int width = Math.Max(1, Console.WindowWidth); - int height = Math.Max(1, Console.WindowHeight); + int width = Math.Max(1, terminal.Width); + int height = Math.Max(1, terminal.Height); if (redraw || width != lastWidth || height != lastHeight) { - Console.Write(Render(state, width, height)); + terminal.Output.Write(Render(state, width, height)); lastWidth = width; lastHeight = height; redraw = false; } - if (Console.KeyAvailable) + if (terminal.KeyAvailable) { - if (!state.Handle(Console.ReadKey(intercept: true), Math.Max(1, height - 7))) { break; } + if (!state.Handle(terminal.ReadKey(), Math.Max(1, height - 7))) { break; } redraw = true; } else { - cancellationToken.WaitHandle.WaitOne(80); + terminal.Wait(cancellationToken); } } } finally { - Console.Write("\u001b[0m\u001b[?25h\u001b[?1049l"); - Console.TreatControlCAsInput = originalControlC; + try { terminal.Output.Write("\u001b[0m\u001b[?25h\u001b[?1049l"); } + finally { terminal.ControlCAsInput = originalControlC; } } } diff --git a/cli/src/SyncSql.Cli/Parsing/ParserTerminal.cs b/cli/src/SyncSql.Cli/Parsing/ParserTerminal.cs new file mode 100644 index 0000000..a85a4a5 --- /dev/null +++ b/cli/src/SyncSql.Cli/Parsing/ParserTerminal.cs @@ -0,0 +1,21 @@ +namespace SyncSql.Cli.Parsing; + +// Keep the operating-system console boundary separate from navigation and lifecycle logic. +internal class ParserTerminal +{ + public virtual TextWriter Output => Console.Out; + public virtual TextWriter Error => Console.Error; + public virtual bool InputRedirected => Console.IsInputRedirected; + public virtual bool OutputRedirected => Console.IsOutputRedirected; + public virtual string? TerminalType => Environment.GetEnvironmentVariable("TERM"); + public virtual int Width => Console.WindowWidth; + public virtual int Height => Console.WindowHeight; + public virtual bool KeyAvailable => Console.KeyAvailable; + public virtual bool ControlCAsInput + { + get => Console.TreatControlCAsInput; + set => Console.TreatControlCAsInput = value; + } + public virtual ConsoleKeyInfo ReadKey() => Console.ReadKey(intercept: true); + public virtual void Wait(CancellationToken cancellationToken) => cancellationToken.WaitHandle.WaitOne(80); +} diff --git a/cli/src/SyncSql.Cli/Parsing/SqlInspection.cs b/cli/src/SyncSql.Cli/Parsing/SqlInspection.cs index 5ba2bf4..9391428 100644 --- a/cli/src/SyncSql.Cli/Parsing/SqlInspection.cs +++ b/cli/src/SyncSql.Cli/Parsing/SqlInspection.cs @@ -46,10 +46,11 @@ public static SqlInspection Parse(string path, string sql, string engine) { if (entry.Tree is ParserRuleContext context) { - int offset = context.Start?.StartIndex ?? 0; + // ANTLR sets Start on every entered rule, including recovered/empty rules. + int offset = context.Start.StartIndex; int end = context.Stop?.StopIndex + 1 ?? offset; nodes.Add(new SqlPiece(new string(' ', Math.Min(entry.Depth, 24) * 2) + parser.RuleNames[context.RuleIndex], - offset, Math.Max(0, end - offset), context.Start?.Line ?? 0, (context.Start?.Column ?? 0) + 1)); + offset, Math.Max(0, end - offset), context.Start.Line, context.Start.Column + 1)); } for (int i = entry.Tree.ChildCount - 1; i >= 0; i--) { @@ -59,7 +60,7 @@ public static SqlInspection Parse(string path, string sql, string engine) stream.Fill(); foreach (IToken token in stream.GetTokens()) { - tokens.Add(new SqlPiece(parser.Vocabulary.GetSymbolicName(token.Type) ?? "EOF", token.StartIndex, + tokens.Add(new SqlPiece(parser.Vocabulary.GetSymbolicName(token.Type), token.StartIndex, Math.Max(0, token.StopIndex - token.StartIndex + 1), token.Line, token.Column + 1, $"Channel: {token.Channel}")); } @@ -108,7 +109,7 @@ public static SqlInspection Parse(string path, string sql, string engine) pending.Push((child, entry.Depth + 1)); } } - foreach (TSqlParserToken token in root.ScriptTokenStream ?? []) + foreach (TSqlParserToken token in root.ScriptTokenStream) { tokens.Add(new SqlPiece(token.TokenType.ToString(), token.Offset, token.Text?.Length ?? 0, token.Line, token.Column)); } @@ -135,7 +136,7 @@ private sealed class OracleErrors(List diagnostics) : BaseErrorListene { public override void SyntaxError(TextWriter output, IRecognizer recognizer, IToken offendingSymbol, int line, int charPositionInLine, string msg, RecognitionException e) => - diagnostics.Add(new SqlPiece("Parser error", offendingSymbol?.StartIndex ?? 0, 0, line, charPositionInLine + 1, msg)); + diagnostics.Add(new SqlPiece("Parser error", offendingSymbol.StartIndex, 0, line, charPositionInLine + 1, msg)); public void SyntaxError(TextWriter output, IRecognizer recognizer, int offendingSymbol, int line, int charPositionInLine, string msg, RecognitionException e) => diff --git a/cli/tests/SyncSql.Cli.Tests/ParserCommandTests.cs b/cli/tests/SyncSql.Cli.Tests/ParserCommandTests.cs index 28cc408..6db5055 100644 --- a/cli/tests/SyncSql.Cli.Tests/ParserCommandTests.cs +++ b/cli/tests/SyncSql.Cli.Tests/ParserCommandTests.cs @@ -125,4 +125,32 @@ public async Task CommandRejectsMissingFilesAndInvalidArguments() } private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + [Fact] + public void LegacySqlUsesCleanFallbackTree() + { + SqlInspection result = SqlInspection.Parse("legacy.sql", "RAISERROR 50001 'legacy error';", "mssql"); + Assert.False(result.HasErrors); + Assert.Contains(result.Sections[0].Pieces, p => p.Name.Contains("RaiseError", StringComparison.Ordinal)); + } + + [Fact] + public void OracleLexerErrorsAreVisibleWithLocations() + { + SqlInspection result = SqlInspection.Parse("lexer.sql", "SELECT \u0001 FROM dual;", "oracle"); + Assert.True(result.HasErrors); + Assert.Contains(result.Sections[5].Pieces, p => p.Name == "Lexer error" && p.Line == 1 && p.Column == 8); + } + + [Fact] + public void SemanticDetailsWorkWithoutSourceLocations() + { + SqlInspection result = SqlInspection.Parse("queue.sql", "RECEIVE * FROM dbo.WorkQueue;", "mssql"); + SqlPiece reference = Assert.Single(result.Sections[2].Pieces); + string detail = result.Describe(reference); + Assert.Contains("dbo.WorkQueue", detail); + Assert.Contains("Type: Queues", detail); + Assert.DoesNotContain("Line", detail); + Assert.Contains("clamped", result.Describe(new SqlPiece("clamped", -5, int.MaxValue))); + } } diff --git a/cli/tests/SyncSql.Cli.Tests/ParserDashboardLifecycleTests.cs b/cli/tests/SyncSql.Cli.Tests/ParserDashboardLifecycleTests.cs new file mode 100644 index 0000000..86a224a --- /dev/null +++ b/cli/tests/SyncSql.Cli.Tests/ParserDashboardLifecycleTests.cs @@ -0,0 +1,148 @@ +using System.CommandLine; +using System.Globalization; +using SyncSql.Cli.Commands; +using SyncSql.Cli.Parsing; + +namespace SyncSql.Cli.Tests; + +public sealed class ParserDashboardLifecycleTests +{ + [Fact] + public void LoopRedrawsOnKeysAndBothResizeDimensionsAndRestoresConsole() + { + TestTerminal terminal = new(); + terminal.Keys.Enqueue(Key(ConsoleKey.DownArrow)); + terminal.OnWait = count => + { + switch (count) + { + case 1: break; // An idle tick must not redraw. + case 2: terminal.Columns++; break; + case 3: terminal.Rows++; break; + case 4: terminal.Keys.Enqueue(Key(ConsoleKey.Q)); break; + } + }; + ParserDashboard.Run(Inspection(), terminal, CancellationToken.None); + string output = terminal.Writer.ToString(); + Assert.Equal(4, output.Split("SYNCSQL / PARSER", StringSplitOptions.None).Length - 1); + Assert.StartsWith("\u001b[?1049h\u001b[?25l", output, StringComparison.Ordinal); + Assert.EndsWith("\u001b[0m\u001b[?25h\u001b[?1049l", output, StringComparison.Ordinal); + Assert.False(terminal.ControlCAsInput); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void CancellationRestoresOriginalControlCMode(bool originalMode) + { + using CancellationTokenSource cancellation = new(); + TestTerminal terminal = new() { ControlCAsInput = originalMode, OnWait = _ => cancellation.Cancel() }; + ParserDashboard.Run(Inspection(), terminal, cancellation.Token); + Assert.Equal(originalMode, terminal.ControlCAsInput); + Assert.Contains("\u001b[?1049l", terminal.Writer.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void ReadFailureRestoresTerminalAndControlCMode() + { + TestTerminal terminal = new() { ReadFailure = new IOException("terminal disconnected") }; + terminal.Keys.Enqueue(Key(ConsoleKey.Q)); + Assert.Throws(() => ParserDashboard.Run(Inspection(), terminal, CancellationToken.None)); + Assert.False(terminal.ControlCAsInput); + Assert.EndsWith("\u001b[0m\u001b[?25h\u001b[?1049l", terminal.Writer.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void OutputFailureStillRestoresControlCMode() + { + TestTerminal terminal = new() { FailedOutput = new DisconnectedWriter() }; + Assert.Throws(() => ParserDashboard.Run(Inspection(), terminal, CancellationToken.None)); + Assert.False(terminal.ControlCAsInput); + } + + [Fact] + public async Task CommandUsesInteractiveTerminalAndNeverChangesInputFile() + { + string path = Path.GetTempFileName(); + try + { + const string sql = "SELECT 1;"; + await File.WriteAllTextAsync(path, sql); + TestTerminal terminal = new(); + terminal.Keys.Enqueue(Key(ConsoleKey.Q)); + int exit = await new RootCommand { ParserCommand.Build(terminal) }.Parse(["parser", "--file", path]).InvokeAsync(); + Assert.Equal(0, exit); + Assert.Contains("SYNCSQL / PARSER", terminal.Writer.ToString()); + Assert.Equal(sql, await File.ReadAllTextAsync(path)); + } + finally { File.Delete(path); } + } + + [Theory] + [InlineData(true, false, false, null, true)] + [InlineData(false, true, false, null, true)] + [InlineData(false, false, true, null, true)] + [InlineData(false, false, false, "dumb", true)] + [InlineData(false, false, false, "xterm", false)] + [InlineData(false, false, false, null, false)] + public void PlainModeHonorsRedirectsAndTerminalCapabilities(bool requested, bool input, bool output, string? term, bool expected) => + Assert.Equal(expected, ParserCommand.UsePlainOutput(requested, input, output, term)); + + [Fact] + public void ConsoleBoundaryReportsRedirectedInputInsteadOfBlocking() + { + // VSTest redirects stdin. A native ReadKey must fail promptly, never wait for a key. + ParserTerminal terminal = new(); + Assert.True(terminal.InputRedirected); + Assert.Same(Console.Out, terminal.Output); + Assert.Same(Console.Error, terminal.Error); + Assert.Equal(Console.IsOutputRedirected, terminal.OutputRedirected); + Assert.Equal(Environment.GetEnvironmentVariable("TERM"), terminal.TerminalType); + AssertConsoleOperation(() => Assert.True(terminal.Width >= 0)); + AssertConsoleOperation(() => Assert.True(terminal.Height >= 0)); + Assert.IsType(Record.Exception(() => terminal.ReadKey())); + AssertConsoleOperation(() => _ = terminal.KeyAvailable); + // Whether console-mode operations succeed with redirected handles is OS-dependent. + bool mode = false; + AssertConsoleOperation(() => mode = terminal.ControlCAsInput); + AssertConsoleOperation(() => terminal.ControlCAsInput = mode); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + terminal.Wait(cancellation.Token); + } + + private static void AssertConsoleOperation(Action action) => + Assert.True(Record.Exception(action) is null or IOException or InvalidOperationException); + + internal static ConsoleKeyInfo Key(ConsoleKey key, char character = '\0', bool shift = false, bool control = false) => + new(character, key, shift, false, control); + + private static SqlInspection Inspection() => SqlInspection.Parse("query.sql", "SELECT 1;", "mssql"); + + private sealed class TestTerminal : ParserTerminal + { + private int _waits; + public StringWriter Writer { get; } = new(CultureInfo.InvariantCulture); + public Queue Keys { get; } = new(); + public int Columns { get; set; } = 100; + public int Rows { get; set; } = 24; + public Action? OnWait { get; set; } + public IOException? ReadFailure { get; init; } + public TextWriter? FailedOutput { get; init; } + public override TextWriter Output => FailedOutput ?? Writer; + public override bool InputRedirected => false; + public override bool OutputRedirected => false; + public override string? TerminalType => "xterm"; + public override int Width => Columns; + public override int Height => Rows; + public override bool KeyAvailable => Keys.Count > 0; + public override bool ControlCAsInput { get; set; } + public override ConsoleKeyInfo ReadKey() => ReadFailure is null ? Keys.Dequeue() : throw ReadFailure; + public override void Wait(CancellationToken cancellationToken) => OnWait?.Invoke(++_waits); + } + + private sealed class DisconnectedWriter : StringWriter + { + public override void Write(string? value) => throw new IOException("terminal disconnected"); + } +} diff --git a/cli/tests/SyncSql.Cli.Tests/ParserDashboardNavigationTests.cs b/cli/tests/SyncSql.Cli.Tests/ParserDashboardNavigationTests.cs new file mode 100644 index 0000000..1038869 --- /dev/null +++ b/cli/tests/SyncSql.Cli.Tests/ParserDashboardNavigationTests.cs @@ -0,0 +1,92 @@ +using SyncSql.Cli.Parsing; +using static SyncSql.Cli.Tests.ParserDashboardLifecycleTests; + +namespace SyncSql.Cli.Tests; + +public sealed class ParserDashboardNavigationTests +{ + [Fact] + public void NavigationWrapsSectionsAndBoundsSelections() + { + DashboardState state = new(SqlInspection.Parse("query.sql", "SELECT 1;", "mssql")); + state.Handle(Key(ConsoleKey.UpArrow), 5); + Assert.Equal(0, state.Selected); + state.Handle(Key(ConsoleKey.DownArrow), 5); + Assert.Equal(1, state.Selected); + state.Handle(Key(ConsoleKey.K), 5); + Assert.Equal(0, state.Selected); + state.Handle(Key(ConsoleKey.J), 5); + state.Handle(Key(ConsoleKey.Home), 5); + Assert.Equal(0, state.Selected); + state.Handle(Key(ConsoleKey.Tab, shift: true), 5); + Assert.Equal(6, state.Section); + state.Handle(Key(ConsoleKey.RightArrow), 5); + Assert.Equal(0, state.Section); + state.Handle(Key(ConsoleKey.D7, '7'), 5); + Assert.Equal(6, state.Section); + state.Handle(Key(ConsoleKey.D7, '7'), 5); + state.Handle(Key(ConsoleKey.D8, '8'), 5); + state.Handle(Key(ConsoleKey.C), 5); + Assert.Equal(6, state.Section); + state.Handle(Key(ConsoleKey.End), 5); + state.Handle(Key(ConsoleKey.DownArrow), 5); + Assert.Equal(0, state.Selected); + state.Handle(Key(ConsoleKey.PageDown), 5); + state.Handle(Key(ConsoleKey.PageUp), 5); + state.Handle(Key(ConsoleKey.PageUp), 5); + Assert.Equal(0, state.DetailRow); + state.Handle(Key(ConsoleKey.L), 5); + state.Handle(Key(ConsoleKey.H), 5); + state.Handle(Key(ConsoleKey.H), 5); + Assert.Equal(0, state.DetailColumn); + Assert.False(state.Handle(Key(ConsoleKey.C, control: true), 5)); + Assert.False(state.Handle(Key(ConsoleKey.Escape), 5)); + } + + [Fact] + public void EditingFilterSupportsDeleteCancelIgnoredKeysAndDescriptionMatches() + { + SqlInspection inspection = new("query.sql", "", "mssql", [new("Objects", [new("Orders", Description: "Dynamic"), new("Other")])], false); + DashboardState state = new(inspection); + state.Handle(Key(ConsoleKey.Oem2, '/'), 5); + state.Handle(Key(ConsoleKey.D, 'd'), 5); + Assert.Single(state.Items); + Assert.Contains("Enter to apply", ParserDashboard.Render(state, 120, 12)); + state.Handle(Key(ConsoleKey.LeftArrow), 5); + Assert.Equal("d", state.Filter); + state.Handle(Key(ConsoleKey.Backspace), 5); + state.Handle(Key(ConsoleKey.Backspace), 5); + Assert.Equal("", state.Filter); + state.Handle(Key(ConsoleKey.Z, 'z'), 5); + Assert.Empty(state.Items); + state.Handle(Key(ConsoleKey.Escape), 5); + Assert.False(state.EditingFilter); + Assert.Equal(2, state.Items.Count); + } + + [Fact] + public void RendererShowsParseErrorsAndClampsDetailAfterPanningAndResizing() + { + DashboardState state = new(SqlInspection.Parse("bad.sql", "SELECT FROM ;", "mssql")); + Assert.Contains("PARSE ERRORS", ParserDashboard.Render(state, 100, 24)); + state.Handle(Key(ConsoleKey.D7, '7'), 5); + state.Handle(Key(ConsoleKey.PageDown), 100); + state.Handle(Key(ConsoleKey.L), 5); + string rendered = ParserDashboard.Render(state, 100, 24); + Assert.Equal(0, state.DetailRow); + Assert.Contains("|", rendered); + Assert.Contains("Resize terminal", ParserDashboard.Render(state, 100, 10)); + Assert.Contains("Resize terminal", ParserDashboard.Render(state, 60, 24)); + } + + [Fact] + public void LongPathsAndSqlStayInsideTheTerminalViewport() + { + string text = new('x', 300); + DashboardState state = new(SqlInspection.Parse(text + ".sql", $"SELECT '{text}';", "mssql")); + string rendered = ParserDashboard.Render(state, 80, 24); + string visible = System.Text.RegularExpressions.Regex.Replace(rendered, "\u001b\\[[0-9;]*[Hm]", ""); + Assert.All(visible.Split("\r\n", StringSplitOptions.None), row => Assert.Equal(79, row.Length)); + Assert.DoesNotContain(text, visible); + } +}