Skip to content

Commit 5ddf970

Browse files
authored
adds formatter option for preserving comments (redo) (#187)
1 parent c152271 commit 5ddf970

File tree

10 files changed

+1279
-1
lines changed

10 files changed

+1279
-1
lines changed
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
//------------------------------------------------------------------------------
2+
// <copyright file="SqlScriptGeneratorVisitor.Comments.cs" company="Microsoft">
3+
// Copyright (c) Microsoft Corporation. All rights reserved.
4+
// </copyright>
5+
//------------------------------------------------------------------------------
6+
using System;
7+
using System.Collections.Generic;
8+
9+
namespace Microsoft.SqlServer.TransactSql.ScriptDom.ScriptGenerator
10+
{
11+
internal abstract partial class SqlScriptGeneratorVisitor
12+
{
13+
#region Comment Tracking Fields
14+
15+
/// <summary>
16+
/// Tracks the last token index processed for comment emission.
17+
/// Used to find comments between visited fragments.
18+
/// </summary>
19+
private int _lastProcessedTokenIndex = -1;
20+
21+
/// <summary>
22+
/// The current script's token stream, set when visiting begins.
23+
/// </summary>
24+
private IList<TSqlParserToken> _currentTokenStream;
25+
26+
/// <summary>
27+
/// Tracks which comment tokens have already been emitted to avoid duplicates.
28+
/// </summary>
29+
private readonly HashSet<TSqlParserToken> _emittedComments = new HashSet<TSqlParserToken>();
30+
31+
/// <summary>
32+
/// Tracks whether leading (file-level) comments have been emitted.
33+
/// </summary>
34+
private bool _leadingCommentsEmitted = false;
35+
36+
#endregion
37+
38+
#region Comment Preservation Methods
39+
40+
/// <summary>
41+
/// Sets the token stream for comment tracking.
42+
/// Call this before visiting the root node when PreserveComments is enabled.
43+
/// </summary>
44+
/// <param name="tokenStream">The token stream from the parsed script.</param>
45+
protected void SetTokenStreamForComments(IList<TSqlParserToken> tokenStream)
46+
{
47+
_currentTokenStream = tokenStream;
48+
_lastProcessedTokenIndex = -1;
49+
_emittedComments.Clear();
50+
_leadingCommentsEmitted = false;
51+
}
52+
53+
/// <summary>
54+
/// Emits comments that appear before the first fragment in the script (file-level leading comments).
55+
/// Called once when generating the first fragment.
56+
/// </summary>
57+
/// <param name="fragment">The first fragment being generated.</param>
58+
protected void EmitLeadingComments(TSqlFragment fragment)
59+
{
60+
if (!_options.PreserveComments || _currentTokenStream == null || fragment == null)
61+
{
62+
return;
63+
}
64+
65+
if (fragment.FirstTokenIndex <= 0)
66+
{
67+
return;
68+
}
69+
70+
for (int i = 0; i < fragment.FirstTokenIndex && i < _currentTokenStream.Count; i++)
71+
{
72+
var token = _currentTokenStream[i];
73+
if (IsCommentToken(token) && !_emittedComments.Contains(token))
74+
{
75+
EmitCommentToken(token, isLeading: true);
76+
_emittedComments.Add(token);
77+
}
78+
}
79+
}
80+
81+
/// <summary>
82+
/// Emits comments that appear in the gap between the last emitted token and the current fragment.
83+
/// This captures comments embedded within sub-expressions.
84+
/// </summary>
85+
/// <param name="fragment">The fragment about to be generated.</param>
86+
protected void EmitGapComments(TSqlFragment fragment)
87+
{
88+
if (!_options.PreserveComments || _currentTokenStream == null || fragment == null)
89+
{
90+
return;
91+
}
92+
93+
int startIndex = _lastProcessedTokenIndex + 1;
94+
int endIndex = fragment.FirstTokenIndex;
95+
96+
if (endIndex <= startIndex)
97+
{
98+
return;
99+
}
100+
101+
for (int i = startIndex; i < endIndex && i < _currentTokenStream.Count; i++)
102+
{
103+
var token = _currentTokenStream[i];
104+
if (IsCommentToken(token) && !_emittedComments.Contains(token))
105+
{
106+
EmitCommentToken(token, isLeading: true);
107+
_emittedComments.Add(token);
108+
_lastProcessedTokenIndex = i;
109+
}
110+
}
111+
}
112+
113+
/// <summary>
114+
/// Emits trailing comments that appear immediately after the fragment.
115+
/// </summary>
116+
/// <param name="fragment">The fragment that was just generated.</param>
117+
protected void EmitTrailingComments(TSqlFragment fragment)
118+
{
119+
if (!_options.PreserveComments || _currentTokenStream == null || fragment == null)
120+
{
121+
return;
122+
}
123+
124+
int lastTokenIndex = fragment.LastTokenIndex;
125+
if (lastTokenIndex < 0 || lastTokenIndex >= _currentTokenStream.Count)
126+
{
127+
return;
128+
}
129+
130+
// Scan for comments immediately following the fragment
131+
for (int i = lastTokenIndex + 1; i < _currentTokenStream.Count; i++)
132+
{
133+
var token = _currentTokenStream[i];
134+
135+
if (IsCommentToken(token) && !_emittedComments.Contains(token))
136+
{
137+
EmitCommentToken(token, isLeading: false);
138+
_emittedComments.Add(token);
139+
_lastProcessedTokenIndex = i;
140+
}
141+
else if (token.TokenType != TSqlTokenType.WhiteSpace)
142+
{
143+
// Stop at next non-whitespace, non-comment token
144+
break;
145+
}
146+
}
147+
}
148+
149+
/// <summary>
150+
/// Updates tracking after generating a fragment.
151+
/// </summary>
152+
/// <param name="fragment">The fragment that was just generated.</param>
153+
protected void UpdateLastProcessedIndex(TSqlFragment fragment)
154+
{
155+
if (fragment != null && fragment.LastTokenIndex > _lastProcessedTokenIndex)
156+
{
157+
_lastProcessedTokenIndex = fragment.LastTokenIndex;
158+
}
159+
}
160+
161+
/// <summary>
162+
/// Called from GenerateFragmentIfNotNull to handle comments before generating a fragment.
163+
/// This is the key integration point that enables comments within sub-expressions.
164+
/// </summary>
165+
/// <param name="fragment">The fragment about to be generated.</param>
166+
protected void HandleCommentsBeforeFragment(TSqlFragment fragment)
167+
{
168+
if (!_options.PreserveComments || _currentTokenStream == null || fragment == null)
169+
{
170+
return;
171+
}
172+
173+
// Emit file-level leading comments once
174+
if (!_leadingCommentsEmitted)
175+
{
176+
EmitLeadingComments(fragment);
177+
_leadingCommentsEmitted = true;
178+
}
179+
180+
// Emit any comments in the gap between last processed token and this fragment
181+
EmitGapComments(fragment);
182+
}
183+
184+
/// <summary>
185+
/// Called from GenerateFragmentIfNotNull to handle comments after generating a fragment.
186+
/// </summary>
187+
/// <param name="fragment">The fragment that was just generated.</param>
188+
protected void HandleCommentsAfterFragment(TSqlFragment fragment)
189+
{
190+
if (!_options.PreserveComments || _currentTokenStream == null || fragment == null)
191+
{
192+
return;
193+
}
194+
195+
// Emit trailing comments and update tracking
196+
EmitTrailingComments(fragment);
197+
UpdateLastProcessedIndex(fragment);
198+
}
199+
200+
/// <summary>
201+
/// Emits a comment token to the output.
202+
/// </summary>
203+
/// <param name="token">The comment token.</param>
204+
/// <param name="isLeading">True if this is a leading comment, false for trailing.</param>
205+
private void EmitCommentToken(TSqlParserToken token, bool isLeading)
206+
{
207+
if (token == null)
208+
{
209+
return;
210+
}
211+
212+
if (token.TokenType == TSqlTokenType.SingleLineComment)
213+
{
214+
if (!isLeading)
215+
{
216+
// Trailing: add space before comment
217+
_writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1));
218+
}
219+
220+
_writer.AddToken(new TSqlParserToken(TSqlTokenType.SingleLineComment, token.Text));
221+
222+
if (isLeading)
223+
{
224+
// After a leading comment, add newline
225+
_writer.NewLine();
226+
}
227+
}
228+
else if (token.TokenType == TSqlTokenType.MultilineComment)
229+
{
230+
if (!isLeading)
231+
{
232+
// Trailing: add space before comment
233+
_writer.AddToken(ScriptGeneratorSupporter.CreateWhitespaceToken(1));
234+
}
235+
236+
_writer.AddToken(new TSqlParserToken(TSqlTokenType.MultilineComment, token.Text));
237+
238+
if (isLeading)
239+
{
240+
// After a leading multi-line comment, add newline
241+
_writer.NewLine();
242+
}
243+
}
244+
}
245+
246+
/// <summary>
247+
/// Emits any remaining comments at the end of the token stream.
248+
/// Call this after visiting the root fragment to capture comments that appear
249+
/// after the last statement (end-of-script comments).
250+
/// </summary>
251+
protected void EmitRemainingComments()
252+
{
253+
if (!_options.PreserveComments || _currentTokenStream == null)
254+
{
255+
return;
256+
}
257+
258+
// Scan from the last processed token to the end of the token stream
259+
for (int i = _lastProcessedTokenIndex + 1; i < _currentTokenStream.Count; i++)
260+
{
261+
var token = _currentTokenStream[i];
262+
if (IsCommentToken(token) && !_emittedComments.Contains(token))
263+
{
264+
// End-of-script comments: add newline before, emit comment
265+
_writer.NewLine();
266+
_writer.AddToken(new TSqlParserToken(token.TokenType, token.Text));
267+
_emittedComments.Add(token);
268+
}
269+
}
270+
}
271+
272+
/// <summary>
273+
/// Checks if a token is a comment token.
274+
/// </summary>
275+
private static bool IsCommentToken(TSqlParserToken token)
276+
{
277+
return token != null &&
278+
(token.TokenType == TSqlTokenType.SingleLineComment ||
279+
token.TokenType == TSqlTokenType.MultilineComment);
280+
}
281+
282+
#endregion
283+
}
284+
}

SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.TSqlScript.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ partial class SqlScriptGeneratorVisitor
1212
{
1313
public override void ExplicitVisit(TSqlScript node)
1414
{
15+
// Initialize token stream for comment preservation
16+
if (_options.PreserveComments && node.ScriptTokenStream != null)
17+
{
18+
SetTokenStreamForComments(node.ScriptTokenStream);
19+
}
20+
1521
Boolean firstItem = true;
1622
foreach (var item in node.Batches)
1723
{
@@ -28,6 +34,9 @@ public override void ExplicitVisit(TSqlScript node)
2834

2935
GenerateFragmentIfNotNull(item);
3036
}
37+
38+
// Emit any remaining comments at end of script (after the last statement)
39+
EmitRemainingComments();
3140
}
3241
}
3342
}

SqlScriptDom/ScriptDom/SqlServer/ScriptGenerator/SqlScriptGeneratorVisitor.Utils.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,14 @@ protected void GenerateFragmentIfNotNull(TSqlFragment fragment)
171171
{
172172
if (fragment != null)
173173
{
174+
// Handle comments before generating the fragment
175+
// This is the key integration point that enables comments within sub-expressions
176+
HandleCommentsBeforeFragment(fragment);
177+
174178
fragment.Accept(this);
179+
180+
// Handle comments after generating the fragment
181+
HandleCommentsAfterFragment(fragment);
175182
}
176183
}
177184

SqlScriptDom/ScriptDom/SqlServer/Settings/SqlScriptGeneratorOptions.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@
4343
<Setting name="NumNewlinesAfterStatement" type="int" default="1" min="0">
4444
<Summary>Gets or sets the number of newlines to include after each statement</Summary>
4545
</Setting>
46+
<Setting name="PreserveComments" type="bool" default="false">
47+
<Summary>Gets or sets a boolean indicating if comments from the original script should be preserved in the generated output</Summary>
48+
</Setting>
4649
</SettingGroup>
4750
<SettingGroup name="CREATE TABLE">
4851
<Setting name="AlignColumnDefinitionFields" type="bool" default="true">
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
SELECT 1;
2+
3+
SELECT 2;
4+
5+
SELECT 3;
6+
7+
SELECT 4;
8+
9+
SELECT 5;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
SELECT 1;
2+
3+
SELECT *
4+
FROM dbo.MyTable;
5+
6+
SELECT 2;
7+
8+
SELECT 3;
9+
10+
SELECT 4;

Test/SqlDom/Only170SyntaxTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,10 @@ public partial class SqlDomTests
3333
new ParserTest170("OptimizedLockingTests170.sql", nErrors80: 2, nErrors90: 2, nErrors100: 2, nErrors110: 2, nErrors120: 2, nErrors130: 2, nErrors140: 2, nErrors150: 2, nErrors160: 2),
3434
new ParserTest170("CreateEventSessionNotLikePredicate.sql", nErrors80: 2, nErrors90: 1, nErrors100: 1, nErrors110: 1, nErrors120: 1, nErrors130: 0, nErrors140: 0, nErrors150: 0, nErrors160: 0),
3535
// Complex query with VECTOR types - parses syntactically in all versions (optimization fix), but VECTOR type only valid in TSql170
36-
new ParserTest170("ComplexQueryTests170.sql")
36+
new ParserTest170("ComplexQueryTests170.sql"),
37+
// Comment preservation tests - basic SQL syntax works in all versions
38+
new ParserTest170("SingleLineCommentTests170.sql"),
39+
new ParserTest170("MultiLineCommentTests170.sql")
3740
};
3841

3942
private static readonly ParserTest[] SqlAzure170_TestInfos =

0 commit comments

Comments
 (0)