diff --git a/AxialSqlTools/Modules/TsqlFormatterCommentInterleaver.cs b/AxialSqlTools/Modules/TsqlFormatterCommentInterleaver.cs index 9a6e964..1021338 100644 --- a/AxialSqlTools/Modules/TsqlFormatterCommentInterleaver.cs +++ b/AxialSqlTools/Modules/TsqlFormatterCommentInterleaver.cs @@ -53,17 +53,25 @@ public static string InterleaveComments(TSqlFragment originalFragment, TSqlFragm 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); - } + // Collect comments from original and schedule injection BEFORE/AFTER a formatted code token. + var injectBeforeFmtIndex = new Dictionary>(); + var injectAfterFmtIndex = 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); + } + + void ScheduleAfter(int fmtIndex, CommentCluster cluster) + { + if (!injectAfterFmtIndex.TryGetValue(fmtIndex, out var list)) + injectAfterFmtIndex[fmtIndex] = list = new List(); + list.Add(cluster); + } for (int i = 0; i < orig.Count; i++) { @@ -72,26 +80,40 @@ void Schedule(int fmtIndex, CommentCluster cluster) 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); - } + 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 rawCluster = chunk.ToString(); + var clusterText = startsNewLine + ? rawCluster + : (HasNewline(rawCluster) ? rawCluster : rawCluster.TrimEnd()); + var cluster = new CommentCluster(clusterText, startsNewLine, blankLinesBefore); + + if (!startsNewLine) + { + int prevCode = PrevIndex(orig, i, IsCodeToken); + if (prevCode >= 0 && mapOrigToFmt.TryGetValue(prevCode, out int prevFmtIndex)) + { + ScheduleAfter(prevFmtIndex, cluster); + continue; + } + } + + 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(); @@ -105,25 +127,25 @@ void Schedule(int fmtIndex, CommentCluster cluster) 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++; - } + if (injectBeforeFmtIndex.TryGetValue(jCode, out var toInject)) + { + foreach (var c in toInject) + { + AppendWithLayout(sb, c); + } + } + + sb.Append(fmt[jCode].Text); + cur++; + + if (injectAfterFmtIndex.TryGetValue(jCode, out var toInjectAfter)) + { + foreach (var c in toInjectAfter) + { + AppendWithLayout(sb, c); + } + } + } while (cur < fmt.Count) { sb.Append(fmt[cur].Text); cur++; } @@ -148,19 +170,26 @@ private static TSqlFragment Parse(TSqlParser parser, string sql, out IList 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 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 PrevIndex(IList tokens, int start, Func pred) + { + for (int i = start; i >= 0; i--) + if (pred(tokens[i])) return i; + return -1; + } + + 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) => @@ -196,17 +225,38 @@ private static bool StartsOnNewLine(IList tokens, int commentIn 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; + 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; + + private static bool EndsWithWhitespace(StringBuilder sb) => + sb.Length > 0 && char.IsWhiteSpace(sb[sb.Length - 1]); + + private static void AppendWithLayout(StringBuilder sb, CommentCluster cluster) + { + if (cluster.StartsNewLine) + { + // We want: at least (1 + blankLinesBefore) newlines before the comment. + int required = 1 + cluster.BlankLinesBefore; + int have = TrailingNewlinesOrLineStart(sb); + for (int add = have; add < required; add++) + sb.Append(Environment.NewLine); + } + else if (!EndsWithWhitespace(sb)) + { + sb.Append(' '); + } + + sb.Append(cluster.Text); + } // Normalized comparison key for LCS: (TokenType, normalized text). @@ -306,18 +356,46 @@ private static int FindAnchorAfterComments(IList tokens, int st 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 static int TrailingNewlinesOrLineStart(StringBuilder sb) + { + // Counts newline characters that appear after the last non-whitespace character. + // This treats trailing indentation (spaces/tabs) as being on the same line, avoiding + // inserting an extra blank line when we're already positioned at the start of a line. + 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 if (ch == ' ' || ch == '\t') + { + continue; + } + else + { + break; + } + } + + // If we reached the start without finding non-whitespace, we are effectively at line start. + if (c == 0 && sb.Length > 0) + { + bool onlyWhitespace = true; + for (int i = 0; i < sb.Length; i++) + { + if (!char.IsWhiteSpace(sb[i])) { onlyWhitespace = false; break; } + } + if (onlyWhitespace) return 1; + } + + return c; + } private sealed class CommentCluster {