From fc6d5386d95b6dbed320e15f165e7a88938944b0 Mon Sep 17 00:00:00 2001 From: Alex Bochkov Date: Sat, 13 Jun 2026 04:15:15 -0500 Subject: [PATCH] Add configurable T-SQL parser version --- AxialSqlTools/AxialSqlTools.csproj | 1 + .../Commands/SelectCurrentStatementCommand.cs | 4 +- .../Commands/ToggleBlockCommentCommand.cs | 2 +- .../Modules/ScriptObjectDefinition.cs | 4 +- AxialSqlTools/Modules/SettingsManager.cs | 61 ++ AxialSqlTools/Modules/TSqlParserFactory.cs | 68 ++ AxialSqlTools/Modules/TsqlFormatter.cs | 7 +- .../TsqlFormatterCommentInterleaver.cs | 727 +++++++++--------- .../WindowSettings/SettingsWindowControl.xaml | 40 + .../SettingsWindowControl.xaml.cs | 28 + 10 files changed, 570 insertions(+), 372 deletions(-) create mode 100644 AxialSqlTools/Modules/TSqlParserFactory.cs diff --git a/AxialSqlTools/AxialSqlTools.csproj b/AxialSqlTools/AxialSqlTools.csproj index ed58b8c..dd217c2 100644 --- a/AxialSqlTools/AxialSqlTools.csproj +++ b/AxialSqlTools/AxialSqlTools.csproj @@ -157,6 +157,7 @@ + diff --git a/AxialSqlTools/Commands/SelectCurrentStatementCommand.cs b/AxialSqlTools/Commands/SelectCurrentStatementCommand.cs index e638f06..0b52f11 100644 --- a/AxialSqlTools/Commands/SelectCurrentStatementCommand.cs +++ b/AxialSqlTools/Commands/SelectCurrentStatementCommand.cs @@ -71,7 +71,7 @@ private void Execute(object sender, EventArgs e) if (string.IsNullOrEmpty(fullText)) return; - // Try parsing with TSql170Parser + // Try parsing with the configured T-SQL parser if (TrySelectWithParser(fullText, cursorLine, cursorColumn, selection)) return; @@ -94,7 +94,7 @@ private bool TrySelectWithParser(string fullText, int cursorLine, int cursorColu { ThreadHelper.ThrowIfNotOnUIThread(); - TSql170Parser sqlParser = new TSql170Parser(false); + TSqlParser sqlParser = TSqlParserFactory.Create(false); IList parseErrors; TSqlFragment fragment = sqlParser.Parse(new StringReader(fullText), out parseErrors); diff --git a/AxialSqlTools/Commands/ToggleBlockCommentCommand.cs b/AxialSqlTools/Commands/ToggleBlockCommentCommand.cs index 149526d..ceab1ad 100644 --- a/AxialSqlTools/Commands/ToggleBlockCommentCommand.cs +++ b/AxialSqlTools/Commands/ToggleBlockCommentCommand.cs @@ -155,7 +155,7 @@ private bool TryFindEnclosingBlockComment( openPosition = default(TextPosition); closePosition = default(TextPosition); - var parser = new TSql170Parser(initialQuotedIdentifiers: true); + var parser = TSqlParserFactory.Create(initialQuotedIdentifiers: true); IList errors; IList tokens = parser.GetTokenStream(new StringReader(text), out errors); foreach (TSqlParserToken token in tokens) diff --git a/AxialSqlTools/Modules/ScriptObjectDefinition.cs b/AxialSqlTools/Modules/ScriptObjectDefinition.cs index 78eac51..913b7b7 100644 --- a/AxialSqlTools/Modules/ScriptObjectDefinition.cs +++ b/AxialSqlTools/Modules/ScriptObjectDefinition.cs @@ -244,14 +244,14 @@ SELECT PARSENAME(@FullName, 3) AS DatabaseName, // additional format to make it pretty if (selectedObject.TypeDesc == "USER_TABLE") { - TSql170Parser sqlParser = new TSql170Parser(false); + TSqlParser sqlParser = TSqlParserFactory.Create(false); IList parseErrors = new List(); TSqlFragment result = sqlParser.Parse(new StringReader(fullScriptResult), out parseErrors); // leave it as is if for some reason we can't format it if (parseErrors.Count == 0) { - Sql170ScriptGenerator gen = new Sql170ScriptGenerator(); + SqlScriptGenerator gen = TSqlParserFactory.CreateScriptGenerator(); gen.Options.AlignClauseBodies = false; gen.Options.IncludeSemicolons = false; gen.GenerateScript(result, out fullScriptResult); diff --git a/AxialSqlTools/Modules/SettingsManager.cs b/AxialSqlTools/Modules/SettingsManager.cs index 665c867..5b58e62 100644 --- a/AxialSqlTools/Modules/SettingsManager.cs +++ b/AxialSqlTools/Modules/SettingsManager.cs @@ -252,6 +252,50 @@ public class SmtpSettings public bool EnableSsl; } + + [JsonConverter(typeof(StringEnumConverter))] + public enum TSqlParserVersion + { + Sql80, + Sql90, + Sql100, + Sql110, + Sql120, + Sql130, + Sql140, + Sql150, + Sql160, + Sql170 + } + + public static string GetTSqlParserVersionDisplayName(TSqlParserVersion version) + { + switch (version) + { + case TSqlParserVersion.Sql80: + return "SQL Server 2000 (8.0)"; + case TSqlParserVersion.Sql90: + return "SQL Server 2005 (9.0)"; + case TSqlParserVersion.Sql100: + return "SQL Server 2008 (10.0)"; + case TSqlParserVersion.Sql110: + return "SQL Server 2012 (11.0)"; + case TSqlParserVersion.Sql120: + return "SQL Server 2014 (12.0)"; + case TSqlParserVersion.Sql130: + return "SQL Server 2016 (13.0)"; + case TSqlParserVersion.Sql140: + return "SQL Server 2017 (14.0)"; + case TSqlParserVersion.Sql150: + return "SQL Server 2019 (15.0)"; + case TSqlParserVersion.Sql160: + return "SQL Server 2022 (16.0)"; + case TSqlParserVersion.Sql170: + default: + return "SQL Server 2025 (17.0)"; + } + } + public enum SnippetReplaceKey { Enter, @@ -550,6 +594,23 @@ public static bool SaveSmtpSettings(SmtpSettings smtpSettings) } + + public static TSqlParserVersion GetTSqlParserVersion() + { + string value = GetRegisterValue("TSqlParserVersion"); + if (Enum.TryParse(value, out TSqlParserVersion version)) + { + return version; + } + + return TSqlParserVersion.Sql170; + } + + public static bool SaveTSqlParserVersion(TSqlParserVersion version) + { + return SaveRegisterValue("TSqlParserVersion", version.ToString()); + } + public static string GetTemplatesFolder() { var folder = GetRegisterValue("ScriptTemplatesFolder"); diff --git a/AxialSqlTools/Modules/TSqlParserFactory.cs b/AxialSqlTools/Modules/TSqlParserFactory.cs new file mode 100644 index 0000000..271e849 --- /dev/null +++ b/AxialSqlTools/Modules/TSqlParserFactory.cs @@ -0,0 +1,68 @@ +using Microsoft.SqlServer.TransactSql.ScriptDom; + +namespace AxialSqlTools +{ + public static class TSqlParserFactory + { + public static TSqlParser Create(bool initialQuotedIdentifiers) + { + return Create(SettingsManager.GetTSqlParserVersion(), initialQuotedIdentifiers); + } + + public static TSqlParser Create(SettingsManager.TSqlParserVersion version, bool initialQuotedIdentifiers) + { + switch (version) + { + case SettingsManager.TSqlParserVersion.Sql80: + return new TSql80Parser(initialQuotedIdentifiers); + case SettingsManager.TSqlParserVersion.Sql90: + return new TSql90Parser(initialQuotedIdentifiers); + case SettingsManager.TSqlParserVersion.Sql100: + return new TSql100Parser(initialQuotedIdentifiers); + case SettingsManager.TSqlParserVersion.Sql110: + return new TSql110Parser(initialQuotedIdentifiers); + case SettingsManager.TSqlParserVersion.Sql120: + return new TSql120Parser(initialQuotedIdentifiers); + case SettingsManager.TSqlParserVersion.Sql130: + return new TSql130Parser(initialQuotedIdentifiers); + case SettingsManager.TSqlParserVersion.Sql140: + return new TSql140Parser(initialQuotedIdentifiers); + case SettingsManager.TSqlParserVersion.Sql150: + return new TSql150Parser(initialQuotedIdentifiers); + case SettingsManager.TSqlParserVersion.Sql160: + return new TSql160Parser(initialQuotedIdentifiers); + case SettingsManager.TSqlParserVersion.Sql170: + default: + return new TSql170Parser(initialQuotedIdentifiers); + } + } + + public static SqlScriptGenerator CreateScriptGenerator() + { + switch (SettingsManager.GetTSqlParserVersion()) + { + case SettingsManager.TSqlParserVersion.Sql80: + return new Sql80ScriptGenerator(); + case SettingsManager.TSqlParserVersion.Sql90: + return new Sql90ScriptGenerator(); + case SettingsManager.TSqlParserVersion.Sql100: + return new Sql100ScriptGenerator(); + case SettingsManager.TSqlParserVersion.Sql110: + return new Sql110ScriptGenerator(); + case SettingsManager.TSqlParserVersion.Sql120: + return new Sql120ScriptGenerator(); + case SettingsManager.TSqlParserVersion.Sql130: + return new Sql130ScriptGenerator(); + case SettingsManager.TSqlParserVersion.Sql140: + return new Sql140ScriptGenerator(); + case SettingsManager.TSqlParserVersion.Sql150: + return new Sql150ScriptGenerator(); + case SettingsManager.TSqlParserVersion.Sql160: + return new Sql160ScriptGenerator(); + case SettingsManager.TSqlParserVersion.Sql170: + default: + return new Sql170ScriptGenerator(); + } + } + } +} diff --git a/AxialSqlTools/Modules/TsqlFormatter.cs b/AxialSqlTools/Modules/TsqlFormatter.cs index cdeb254..e80180d 100644 --- a/AxialSqlTools/Modules/TsqlFormatter.cs +++ b/AxialSqlTools/Modules/TsqlFormatter.cs @@ -291,7 +291,7 @@ public static string FormatCode(string oldCode, SettingsManager.TSqlCodeFormatSe { string resultCode = ""; - TSql170Parser sqlParser = new TSql170Parser(false); + TSqlParser sqlParser = TSqlParserFactory.Create(false); IList parseErrors = new List(); TSqlFragment result = sqlParser.Parse(new StringReader(oldCode), out parseErrors); @@ -313,9 +313,8 @@ public static string FormatCode(string oldCode, SettingsManager.TSqlCodeFormatSe formatSettings = settingsOverride; } - Sql170ScriptGenerator gen = new Sql170ScriptGenerator(); + SqlScriptGenerator gen = TSqlParserFactory.CreateScriptGenerator(); gen.Options.AlignClauseBodies = false; - gen.Options.SqlVersion = SqlVersion.Sql170; //TODO - try to get from current connection if (formatSettings.preserveComments) { @@ -342,7 +341,7 @@ public static string FormatCode(string oldCode, SettingsManager.TSqlCodeFormatSe } - private static string ApplySpecialFormat(string oldCode, TSql170Parser sqlParser, SettingsManager.TSqlCodeFormatSettings formatSettings) + private static string ApplySpecialFormat(string oldCode, TSqlParser sqlParser, SettingsManager.TSqlCodeFormatSettings formatSettings) { IList parseErrors = new List(); diff --git a/AxialSqlTools/Modules/TsqlFormatterCommentInterleaver.cs b/AxialSqlTools/Modules/TsqlFormatterCommentInterleaver.cs index 29122f6..2c27225 100644 --- a/AxialSqlTools/Modules/TsqlFormatterCommentInterleaver.cs +++ b/AxialSqlTools/Modules/TsqlFormatterCommentInterleaver.cs @@ -1,364 +1,365 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using Microsoft.SqlServer.TransactSql.ScriptDom; - -public static class TsqlFormatterCommentInterleaver -{ - /// - /// Formats the original SQL with the given ScriptDom generator, then re-inserts comments - /// from the original text into the formatted SQL using LCS alignment on non-comment tokens. - /// - public static string GenerateWithComments(TSqlFragment sqlFragment, SqlScriptGenerator generator, TSqlParser parser = null) - { - if (sqlFragment == null) throw new ArgumentNullException(nameof(sqlFragment)); - if (generator == null) throw new ArgumentNullException(nameof(generator)); - - if (parser is null) - parser = new TSql170Parser(initialQuotedIdentifiers: true); - - generator.GenerateScript(sqlFragment, out var formattedSql); - - // Re-parse formatted output - var fmtFrag = Parse(parser, formattedSql, out _); - - return InterleaveComments(sqlFragment, fmtFrag); - } - - /// - /// Re-inserts comments from orig into fmt (both already parsed). - /// - public static string InterleaveComments(TSqlFragment originalFragment, TSqlFragment formattedFragment) - { - var orig = originalFragment.ScriptTokenStream ?? throw new InvalidOperationException("Original fragment has no ScriptTokenStream."); - var fmt = formattedFragment.ScriptTokenStream ?? throw new InvalidOperationException("Formatted fragment has no ScriptTokenStream."); - - // Build lists of indices for code (non-comment, non-whitespace) tokens - var origCodeIdx = IndicesWhere(orig, IsCodeToken); - var fmtCodeIdx = IndicesWhere(fmt, IsCodeToken); - - // Build normalized keys for LCS (TokenType + normalized text) - var origKeys = origCodeIdx.Select(i => TokenKey(orig[i])).ToList(); - var fmtKeys = fmtCodeIdx.Select(i => TokenKey(fmt[i])).ToList(); - - // LCS between origKeys and fmtKeys - var pairs = LongestCommonSubsequence(origKeys, fmtKeys); - - // Map: original code token index -> formatted code token index - var mapOrigToFmt = new Dictionary(); - foreach (var (iOrigKey, iFmtKey) in pairs) - { - mapOrigToFmt[origCodeIdx[iOrigKey]] = fmtCodeIdx[iFmtKey]; - } - - // Collect comments from original and schedule injection BEFORE a formatted code token. - var injectBeforeFmtIndex = new Dictionary>(); - var eofComments = new List(); - - // Helper to add a comment to a bucket - void Schedule(int fmtIndex, CommentCluster cluster) - { - if (!injectBeforeFmtIndex.TryGetValue(fmtIndex, out var list)) - injectBeforeFmtIndex[fmtIndex] = list = new List(); - list.Add(cluster); - } - - for (int i = 0; i < orig.Count; i++) - { - if (!IsCommentToken(orig[i])) continue; - - bool startsNewLine = StartsOnNewLine(orig, i); - int blankLinesBefore = startsNewLine ? CountBlankLinesBefore(orig, i) : 0; - - var chunk = new StringBuilder(); - chunk.Append(orig[i].Text); - int k = i + 1; - while (k < orig.Count && IsWsOrComment(orig[k])) - { - chunk.Append(orig[k].Text); - k++; - } - i = k - 1; - - // Anchor to the *next statement header* if the immediate next code token is ';' - int anchorOrig = FindAnchorAfterComments(orig, k); - - var cluster = new CommentCluster(chunk.ToString(), startsNewLine, blankLinesBefore); - - if (anchorOrig >= 0 && mapOrigToFmt.TryGetValue(anchorOrig, out int fmtIndex)) - Schedule(fmtIndex, cluster); - else - eofComments.Add(cluster); - } - - // Emit the formatted stream and inject comments at anchors (before the code token) - var sb = new StringBuilder(); - int cur = 0; - - foreach (var jCode in fmtCodeIdx) - { - while (cur < jCode) - { - sb.Append(fmt[cur].Text); - cur++; - } - - if (injectBeforeFmtIndex.TryGetValue(jCode, out var toInject)) - { - foreach (var c in toInject) - { - if (c.StartsNewLine) - { - // We want: at least (1 + blankLinesBefore) newlines before the comment. - int required = 1 + c.BlankLinesBefore; - int have = TrailingNewlines(sb); - for (int add = have; add < required; add++) - sb.Append(Environment.NewLine); - } - sb.Append(c.Text); - } - } - - sb.Append(fmt[jCode].Text); - cur++; - } - - while (cur < fmt.Count) { sb.Append(fmt[cur].Text); cur++; } - - foreach (var c in eofComments) - { - if (c.StartsNewLine && !EndsWithNewline(sb)) - sb.Append(Environment.NewLine); - sb.Append(c.Text); - } - - return sb.ToString(); - } - - // ----------------- Helpers ----------------- - - private static TSqlFragment Parse(TSqlParser parser, string sql, out IList errors) - { - using (var sr = new StringReader(sql)) - { - var frag = parser.Parse(sr, out errors); - return frag; - } - } - - private static List IndicesWhere(IList tokens, Func pred) - { - var list = new List(tokens.Count); - for (int i = 0; i < tokens.Count; i++) - if (pred(tokens[i])) list.Add(i); - return list; - } - - private static int NextIndex(IList tokens, int start, Func pred) - { - for (int i = start; i < tokens.Count; i++) - if (pred(tokens[i])) return i; - return -1; - } - - private static bool IsCommentToken(TSqlParserToken t) => - t.TokenType == TSqlTokenType.SingleLineComment || - t.TokenType == TSqlTokenType.MultilineComment; - - private static bool IsWhitespaceToken(TSqlParserToken t) => - t.TokenType == TSqlTokenType.WhiteSpace; - - private static bool IsCodeToken(TSqlParserToken t) => - !IsCommentToken(t) && !IsWhitespaceToken(t); - - private static bool HasNewline(string s) => - s.IndexOf('\n') >= 0 || s.IndexOf('\r') >= 0; - - private static bool StartsOnNewLine(IList tokens, int commentIndex) - { - // If the comment is the first token, it's at line start. - if (commentIndex <= 0) return true; - - // Walk left across whitespace; if any contains a newline, the comment starts on a new line. - int k = commentIndex - 1; - //bool sawWs = false; - while (k >= 0 && IsWhitespaceToken(tokens[k])) - { - //sawWs = true; - if (HasNewline(tokens[k].Text)) return true; - k--; - } - - // If there was no whitespace, it's immediately after another token on the same line. - // If there was whitespace but no newline, also same line. - return false; - } - - private static bool EndsWithNewline(StringBuilder sb) - { - if (sb.Length == 0) return false; - char last = sb[sb.Length - 1]; - return last == '\n' || last == '\r'; - } - - private static bool IsWsOrComment(TSqlParserToken t) => - t.TokenType == TSqlTokenType.WhiteSpace || - t.TokenType == TSqlTokenType.SingleLineComment || - t.TokenType == TSqlTokenType.MultilineComment; - - - // Normalized comparison key for LCS: (TokenType, normalized text). - // For identifiers/keywords, T-SQL is generally case-insensitive; normalize to upper. - private static (TSqlTokenType type, string norm) TokenKey(TSqlParserToken t) - { - // Preserve exact text for string/number literals; uppercase for everything else. - switch (t.TokenType) - { - case TSqlTokenType.AsciiStringLiteral: - case TSqlTokenType.UnicodeStringLiteral: - case TSqlTokenType.Integer: - case TSqlTokenType.Real: - case TSqlTokenType.HexLiteral: - return (t.TokenType, t.Text); // keep exact - default: - if (!string.IsNullOrEmpty(t.Text)) - return (t.TokenType, t.Text.ToUpperInvariant()); - else - return (t.TokenType, t.Text); - } - } - - private static bool IsSemicolonToken(TSqlParserToken t) => - t.TokenType == TSqlTokenType.Semicolon || t.Text == ";"; - - private static bool IsStmtHeaderToken(TSqlParserToken t) - { - // Use text so it works across ScriptDom versions. - var k = t.Text.ToUpperInvariant(); - switch (k) - { - case "WITH": - case "SELECT": - case "INSERT": - case "UPDATE": - case "DELETE": - case "MERGE": - case "CREATE": - case "ALTER": - case "DROP": - case "EXEC": - case "EXECUTE": - case "DECLARE": - case "BEGIN": - case "IF": - case "WHILE": - case "RETURN": - case "TRUNCATE": - case "USE": - return true; - default: - return false; - } - } - - private static int CountNewlines(string s) - { - int c = 0; - for (int i = 0; i < s.Length; i++) - if (s[i] == '\n') c++; - return c; - } - - private static int CountBlankLinesBefore(IList tokens, int commentIndex) - { - // Count newlines in whitespace between the previous CODE token and the comment. - int prevCode = -1; - for (int i = commentIndex - 1; i >= 0; i--) - { - if (IsCodeToken(tokens[i])) { prevCode = i; break; } - if (!IsWhitespaceToken(tokens[i]) && !IsCommentToken(tokens[i])) break; - } - - int newlines = 0; - for (int i = prevCode + 1; i < commentIndex; i++) - if (IsWhitespaceToken(tokens[i])) newlines += CountNewlines(tokens[i].Text); - - // One newline ends the previous line; extras are blank lines. - return Math.Max(0, newlines - 1); - } - - private static int FindAnchorAfterComments(IList tokens, int start) - { - // First non-comment, non-whitespace after the cluster - int idx = NextIndex(tokens, start, IsCodeToken); - if (idx < 0) return -1; - - // If it's a semicolon, prefer the *next* statement header if available. - if (IsSemicolonToken(tokens[idx])) - { - int next = NextIndex(tokens, idx + 1, IsCodeToken); - if (next >= 0 && IsStmtHeaderToken(tokens[next])) - return next; // anchor to WITH / SELECT / etc. - } - - return idx; - } - - private static int TrailingNewlines(StringBuilder sb) - { - int c = 0; - for (int i = sb.Length - 1; i >= 0; i--) - { - char ch = sb[i]; - if (ch == '\n') c++; - else if (ch == '\r') continue; - else break; - } - return c; - } - - private sealed class CommentCluster - { - public string Text { get; } - public bool StartsNewLine { get; } - public int BlankLinesBefore { get; } - public CommentCluster(string text, bool startsNewLine, int blankLinesBefore) - { - Text = text; - StartsNewLine = startsNewLine; - BlankLinesBefore = blankLinesBefore; - } - } - - /// - /// Classic LCS over two sequences of keys. Returns list of matched index pairs (iA, iB) in order. - /// - private static List<(int iA, int iB)> LongestCommonSubsequence(IList a, IList b) - where T : IEquatable - { - int n = a.Count, m = b.Count; - var dp = new int[n + 1, m + 1]; - - for (int i = n - 1; i >= 0; i--) - for (int j = m - 1; j >= 0; j--) - dp[i, j] = a[i].Equals(b[j]) ? dp[i + 1, j + 1] + 1 : Math.Max(dp[i + 1, j], dp[i, j + 1]); - - var result = new List<(int, int)>(); - int x = 0, y = 0; - while (x < n && y < m) - { - if (a[x].Equals(b[y])) - { - result.Add((x, y)); - x++; y++; - } - else if (dp[x + 1, y] >= dp[x, y + 1]) - x++; - else - y++; - } - return result; - } +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.SqlServer.TransactSql.ScriptDom; +using AxialSqlTools; + +public static class TsqlFormatterCommentInterleaver +{ + /// + /// Formats the original SQL with the given ScriptDom generator, then re-inserts comments + /// from the original text into the formatted SQL using LCS alignment on non-comment tokens. + /// + public static string GenerateWithComments(TSqlFragment sqlFragment, SqlScriptGenerator generator, TSqlParser parser = null) + { + if (sqlFragment == null) throw new ArgumentNullException(nameof(sqlFragment)); + if (generator == null) throw new ArgumentNullException(nameof(generator)); + + if (parser is null) + parser = TSqlParserFactory.Create(initialQuotedIdentifiers: true); + + generator.GenerateScript(sqlFragment, out var formattedSql); + + // Re-parse formatted output + var fmtFrag = Parse(parser, formattedSql, out _); + + return InterleaveComments(sqlFragment, fmtFrag); + } + + /// + /// Re-inserts comments from orig into fmt (both already parsed). + /// + public static string InterleaveComments(TSqlFragment originalFragment, TSqlFragment formattedFragment) + { + var orig = originalFragment.ScriptTokenStream ?? throw new InvalidOperationException("Original fragment has no ScriptTokenStream."); + var fmt = formattedFragment.ScriptTokenStream ?? throw new InvalidOperationException("Formatted fragment has no ScriptTokenStream."); + + // Build lists of indices for code (non-comment, non-whitespace) tokens + var origCodeIdx = IndicesWhere(orig, IsCodeToken); + var fmtCodeIdx = IndicesWhere(fmt, IsCodeToken); + + // Build normalized keys for LCS (TokenType + normalized text) + var origKeys = origCodeIdx.Select(i => TokenKey(orig[i])).ToList(); + var fmtKeys = fmtCodeIdx.Select(i => TokenKey(fmt[i])).ToList(); + + // LCS between origKeys and fmtKeys + var pairs = LongestCommonSubsequence(origKeys, fmtKeys); + + // Map: original code token index -> formatted code token index + var mapOrigToFmt = new Dictionary(); + foreach (var (iOrigKey, iFmtKey) in pairs) + { + mapOrigToFmt[origCodeIdx[iOrigKey]] = fmtCodeIdx[iFmtKey]; + } + + // Collect comments from original and schedule injection BEFORE a formatted code token. + var injectBeforeFmtIndex = new Dictionary>(); + var eofComments = new List(); + + // Helper to add a comment to a bucket + void Schedule(int fmtIndex, CommentCluster cluster) + { + if (!injectBeforeFmtIndex.TryGetValue(fmtIndex, out var list)) + injectBeforeFmtIndex[fmtIndex] = list = new List(); + list.Add(cluster); + } + + for (int i = 0; i < orig.Count; i++) + { + if (!IsCommentToken(orig[i])) continue; + + bool startsNewLine = StartsOnNewLine(orig, i); + int blankLinesBefore = startsNewLine ? CountBlankLinesBefore(orig, i) : 0; + + var chunk = new StringBuilder(); + chunk.Append(orig[i].Text); + int k = i + 1; + while (k < orig.Count && IsWsOrComment(orig[k])) + { + chunk.Append(orig[k].Text); + k++; + } + i = k - 1; + + // Anchor to the *next statement header* if the immediate next code token is ';' + int anchorOrig = FindAnchorAfterComments(orig, k); + + var cluster = new CommentCluster(chunk.ToString(), startsNewLine, blankLinesBefore); + + if (anchorOrig >= 0 && mapOrigToFmt.TryGetValue(anchorOrig, out int fmtIndex)) + Schedule(fmtIndex, cluster); + else + eofComments.Add(cluster); + } + + // Emit the formatted stream and inject comments at anchors (before the code token) + var sb = new StringBuilder(); + int cur = 0; + + foreach (var jCode in fmtCodeIdx) + { + while (cur < jCode) + { + sb.Append(fmt[cur].Text); + cur++; + } + + if (injectBeforeFmtIndex.TryGetValue(jCode, out var toInject)) + { + foreach (var c in toInject) + { + if (c.StartsNewLine) + { + // We want: at least (1 + blankLinesBefore) newlines before the comment. + int required = 1 + c.BlankLinesBefore; + int have = TrailingNewlines(sb); + for (int add = have; add < required; add++) + sb.Append(Environment.NewLine); + } + sb.Append(c.Text); + } + } + + sb.Append(fmt[jCode].Text); + cur++; + } + + while (cur < fmt.Count) { sb.Append(fmt[cur].Text); cur++; } + + foreach (var c in eofComments) + { + if (c.StartsNewLine && !EndsWithNewline(sb)) + sb.Append(Environment.NewLine); + sb.Append(c.Text); + } + + return sb.ToString(); + } + + // ----------------- Helpers ----------------- + + private static TSqlFragment Parse(TSqlParser parser, string sql, out IList errors) + { + using (var sr = new StringReader(sql)) + { + var frag = parser.Parse(sr, out errors); + return frag; + } + } + + private static List IndicesWhere(IList tokens, Func pred) + { + var list = new List(tokens.Count); + for (int i = 0; i < tokens.Count; i++) + if (pred(tokens[i])) list.Add(i); + return list; + } + + private static int NextIndex(IList tokens, int start, Func pred) + { + for (int i = start; i < tokens.Count; i++) + if (pred(tokens[i])) return i; + return -1; + } + + private static bool IsCommentToken(TSqlParserToken t) => + t.TokenType == TSqlTokenType.SingleLineComment || + t.TokenType == TSqlTokenType.MultilineComment; + + private static bool IsWhitespaceToken(TSqlParserToken t) => + t.TokenType == TSqlTokenType.WhiteSpace; + + private static bool IsCodeToken(TSqlParserToken t) => + !IsCommentToken(t) && !IsWhitespaceToken(t); + + private static bool HasNewline(string s) => + s.IndexOf('\n') >= 0 || s.IndexOf('\r') >= 0; + + private static bool StartsOnNewLine(IList tokens, int commentIndex) + { + // If the comment is the first token, it's at line start. + if (commentIndex <= 0) return true; + + // Walk left across whitespace; if any contains a newline, the comment starts on a new line. + int k = commentIndex - 1; + //bool sawWs = false; + while (k >= 0 && IsWhitespaceToken(tokens[k])) + { + //sawWs = true; + if (HasNewline(tokens[k].Text)) return true; + k--; + } + + // If there was no whitespace, it's immediately after another token on the same line. + // If there was whitespace but no newline, also same line. + return false; + } + + private static bool EndsWithNewline(StringBuilder sb) + { + if (sb.Length == 0) return false; + char last = sb[sb.Length - 1]; + return last == '\n' || last == '\r'; + } + + private static bool IsWsOrComment(TSqlParserToken t) => + t.TokenType == TSqlTokenType.WhiteSpace || + t.TokenType == TSqlTokenType.SingleLineComment || + t.TokenType == TSqlTokenType.MultilineComment; + + + // Normalized comparison key for LCS: (TokenType, normalized text). + // For identifiers/keywords, T-SQL is generally case-insensitive; normalize to upper. + private static (TSqlTokenType type, string norm) TokenKey(TSqlParserToken t) + { + // Preserve exact text for string/number literals; uppercase for everything else. + switch (t.TokenType) + { + case TSqlTokenType.AsciiStringLiteral: + case TSqlTokenType.UnicodeStringLiteral: + case TSqlTokenType.Integer: + case TSqlTokenType.Real: + case TSqlTokenType.HexLiteral: + return (t.TokenType, t.Text); // keep exact + default: + if (!string.IsNullOrEmpty(t.Text)) + return (t.TokenType, t.Text.ToUpperInvariant()); + else + return (t.TokenType, t.Text); + } + } + + private static bool IsSemicolonToken(TSqlParserToken t) => + t.TokenType == TSqlTokenType.Semicolon || t.Text == ";"; + + private static bool IsStmtHeaderToken(TSqlParserToken t) + { + // Use text so it works across ScriptDom versions. + var k = t.Text.ToUpperInvariant(); + switch (k) + { + case "WITH": + case "SELECT": + case "INSERT": + case "UPDATE": + case "DELETE": + case "MERGE": + case "CREATE": + case "ALTER": + case "DROP": + case "EXEC": + case "EXECUTE": + case "DECLARE": + case "BEGIN": + case "IF": + case "WHILE": + case "RETURN": + case "TRUNCATE": + case "USE": + return true; + default: + return false; + } + } + + private static int CountNewlines(string s) + { + int c = 0; + for (int i = 0; i < s.Length; i++) + if (s[i] == '\n') c++; + return c; + } + + private static int CountBlankLinesBefore(IList tokens, int commentIndex) + { + // Count newlines in whitespace between the previous CODE token and the comment. + int prevCode = -1; + for (int i = commentIndex - 1; i >= 0; i--) + { + if (IsCodeToken(tokens[i])) { prevCode = i; break; } + if (!IsWhitespaceToken(tokens[i]) && !IsCommentToken(tokens[i])) break; + } + + int newlines = 0; + for (int i = prevCode + 1; i < commentIndex; i++) + if (IsWhitespaceToken(tokens[i])) newlines += CountNewlines(tokens[i].Text); + + // One newline ends the previous line; extras are blank lines. + return Math.Max(0, newlines - 1); + } + + private static int FindAnchorAfterComments(IList tokens, int start) + { + // First non-comment, non-whitespace after the cluster + int idx = NextIndex(tokens, start, IsCodeToken); + if (idx < 0) return -1; + + // If it's a semicolon, prefer the *next* statement header if available. + if (IsSemicolonToken(tokens[idx])) + { + int next = NextIndex(tokens, idx + 1, IsCodeToken); + if (next >= 0 && IsStmtHeaderToken(tokens[next])) + return next; // anchor to WITH / SELECT / etc. + } + + return idx; + } + + private static int TrailingNewlines(StringBuilder sb) + { + int c = 0; + for (int i = sb.Length - 1; i >= 0; i--) + { + char ch = sb[i]; + if (ch == '\n') c++; + else if (ch == '\r') continue; + else break; + } + return c; + } + + private sealed class CommentCluster + { + public string Text { get; } + public bool StartsNewLine { get; } + public int BlankLinesBefore { get; } + public CommentCluster(string text, bool startsNewLine, int blankLinesBefore) + { + Text = text; + StartsNewLine = startsNewLine; + BlankLinesBefore = blankLinesBefore; + } + } + + /// + /// Classic LCS over two sequences of keys. Returns list of matched index pairs (iA, iB) in order. + /// + private static List<(int iA, int iB)> LongestCommonSubsequence(IList a, IList b) + where T : IEquatable + { + int n = a.Count, m = b.Count; + var dp = new int[n + 1, m + 1]; + + for (int i = n - 1; i >= 0; i--) + for (int j = m - 1; j >= 0; j--) + dp[i, j] = a[i].Equals(b[j]) ? dp[i + 1, j + 1] + 1 : Math.Max(dp[i + 1, j], dp[i, j + 1]); + + var result = new List<(int, int)>(); + int x = 0, y = 0; + while (x < n && y < m) + { + if (a[x].Equals(b[y])) + { + result.Add((x, y)); + x++; y++; + } + else if (dp[x + 1, y] >= dp[x, y + 1]) + x++; + else + y++; + } + return result; + } } \ No newline at end of file diff --git a/AxialSqlTools/WindowSettings/SettingsWindowControl.xaml b/AxialSqlTools/WindowSettings/SettingsWindowControl.xaml index 5e728d3..f134757 100644 --- a/AxialSqlTools/WindowSettings/SettingsWindowControl.xaml +++ b/AxialSqlTools/WindowSettings/SettingsWindowControl.xaml @@ -105,6 +105,46 @@ BorderBrush="{DynamicResource AxialThemeBorderBrush}" BorderThickness="1"> + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AxialSqlTools/WindowSettings/SettingsWindowControl.xaml.cs b/AxialSqlTools/WindowSettings/SettingsWindowControl.xaml.cs index 4f42931..6d53523 100644 --- a/AxialSqlTools/WindowSettings/SettingsWindowControl.xaml.cs +++ b/AxialSqlTools/WindowSettings/SettingsWindowControl.xaml.cs @@ -13,6 +13,7 @@ using System.Windows; using System.Windows.Controls; using System.Collections.ObjectModel; + using System.Linq; using System.Windows.Media; using System.Windows.Navigation; using Microsoft.VisualBasic; @@ -58,6 +59,7 @@ public SettingsWindowControl() _connectionColorRules = new ObservableCollection(); ConnectionColorRulesListView.ItemsSource = _connectionColorRules; + SqlVersion.ItemsSource = GetSqlVersionOptions(); _themeController = new ToolWindowThemeController(this, ApplyThemeBrushResources); @@ -133,6 +135,8 @@ private void LoadSavedSettings() try { + SqlVersion.SelectedValue = SettingsManager.GetTSqlParserVersion(); + ScriptFolder.Text = SettingsManager.GetTemplatesFolder(); var snippetSettings = SettingsManager.GetSnippetSettings(); @@ -243,6 +247,30 @@ private void UpdateQueryHistoryConnectionDetails() } + + private static object[] GetSqlVersionOptions() + { + return Enum.GetValues(typeof(SettingsManager.TSqlParserVersion)) + .Cast() + .OrderByDescending(version => version) + .Select(version => new + { + Version = version, + DisplayName = SettingsManager.GetTSqlParserVersionDisplayName(version) + }) + .ToArray(); + } + + private void Button_SaveGeneralSettings_Click(object sender, RoutedEventArgs e) + { + if (SqlVersion.SelectedValue is SettingsManager.TSqlParserVersion version) + { + SettingsManager.SaveTSqlParserVersion(version); + } + + SavedMessage(); + } + private void Button_SaveScriptFolder_Click(object sender, RoutedEventArgs e) { SettingsManager.SaveTemplatesFolder(ScriptFolder.Text);