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);