diff --git a/src/SheetMe.Core/Catalog/SqlTokenizer.cs b/src/SheetMe.Core/Catalog/SqlTokenizer.cs
new file mode 100644
index 0000000..756538a
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/SqlTokenizer.cs
@@ -0,0 +1,236 @@
+namespace SheetMe.Core.Catalog;
+
+/// SQL 조각의 종류 — 색을 정하는 단위
+public enum SqlTokenKind
+{
+ /// 그 밖(식별자·연산자·공백)
+ Plain,
+ /// 예약어(SELECT, FROM …)
+ Keyword,
+ /// 내장 함수(NVL, TO_CHAR …)
+ Function,
+ /// 숫자 리터럴
+ Number,
+ /// 문자열 리터럴('...')
+ Text,
+ /// 주석(-- 또는 /* */)
+ Comment,
+ /// 치환 변수(<<...>>) — 이 편집기에서 가장 중요한 조각
+ Variable,
+ /// 따옴표가 닫히지 않은 문자열처럼, 그대로 두면 오류가 나는 조각
+ Broken,
+}
+
+/// 텍스트 안의 한 조각
+public readonly record struct SqlToken(int Start, int Length, SqlTokenKind Kind)
+{
+ /// 조각 끝(배타)
+ public int End => Start + Length;
+}
+
+///
+/// SQL 구문 조각내기 — 편집기의 색칠 근거.
+///
+/// 왜 직접 만드는가. 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 했다.
+/// 필요한 것은 완전한 파서가 아니라 보고 판단할 수 있을 만큼의 구분이다:
+/// 예약어·문자열·주석·숫자, 그리고 이 시스템에서만 의미가 있는 <<치환변수>>.
+///
+/// Broken 을 따로 두는 이유. 이 쿼리는 실행 시점에 치환된 뒤 EMR 런타임에서 돌고,
+/// 실패해도 빈 catch 가 삼켜 화면에는 아무 표시가 없다. 닫히지 않은 따옴표처럼
+/// 확실히 깨진 조각은 편집기에서 즉시 눈에 띄어야 한다.
+///
+/// 방언은 Oracle 기준이다(M.CMM.DBAccess 가 Oracle.ManagedDataAccess 를 쓴다).
+///
+public static class SqlTokenizer
+{
+ #region Member Fields
+ /// Oracle SQL 예약어 — 대소문자 무시
+ public static readonly IReadOnlySet Keywords = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "NULL", "IS", "IN", "EXISTS",
+ "BETWEEN", "LIKE", "ORDER", "BY", "GROUP", "HAVING", "DISTINCT", "ALL",
+ "UNION", "INTERSECT", "MINUS", "JOIN", "INNER", "LEFT", "RIGHT", "FULL",
+ "OUTER", "CROSS", "ON", "AS", "CASE", "WHEN", "THEN", "ELSE", "END",
+ "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", "MERGE", "USING",
+ "CREATE", "ALTER", "DROP", "TABLE", "VIEW", "INDEX", "WITH", "DUAL",
+ "ASC", "DESC", "CONNECT", "START", "PRIOR", "LEVEL", "ROWNUM", "ROWID",
+ "PARTITION", "OVER", "FETCH", "FIRST", "NEXT", "ROWS", "ONLY", "OFFSET",
+ "PIVOT", "UNPIVOT", "ANY", "SOME", "CAST", "DECODE",
+ };
+
+ /// 자주 쓰는 Oracle 내장 함수 — 예약어와 색을 달리해 눈에 덜 튀게
+ public static readonly IReadOnlySet Functions = new HashSet(StringComparer.OrdinalIgnoreCase)
+ {
+ "NVL", "NVL2", "COALESCE", "SUBSTR", "SUBSTRB", "INSTR", "LENGTH", "LENGTHB",
+ "TRIM", "LTRIM", "RTRIM", "LPAD", "RPAD", "REPLACE", "TRANSLATE", "UPPER", "LOWER",
+ "TO_CHAR", "TO_DATE", "TO_NUMBER", "TO_TIMESTAMP", "SYSDATE", "SYSTIMESTAMP",
+ "ADD_MONTHS", "MONTHS_BETWEEN", "LAST_DAY", "NEXT_DAY", "TRUNC", "ROUND",
+ "COUNT", "SUM", "AVG", "MIN", "MAX", "ROW_NUMBER", "RANK", "DENSE_RANK",
+ "LISTAGG", "WM_CONCAT", "GREATEST", "LEAST", "MOD", "ABS", "CEIL", "FLOOR",
+ "SIGN", "POWER", "SQRT", "REGEXP_LIKE", "REGEXP_SUBSTR", "REGEXP_REPLACE",
+ };
+ #endregion
+
+ #region Methods
+ ///
+ /// 전체를 조각으로 나눈다 — 조각은 겹치지 않고 순서대로이며, 빈틈은 Plain 으로 채워진다.
+ /// 색칠하는 쪽이 위치 계산을 다시 하지 않도록 전 구간을 덮는다.
+ ///
+ public static IReadOnlyList Tokenize(string text)
+ {
+ var tokens = new List();
+ if (string.IsNullOrEmpty(text))
+ {
+ return tokens;
+ }
+
+ var i = 0;
+ var plainStart = 0;
+
+ void FlushPlain(int upto)
+ {
+ if (upto > plainStart)
+ {
+ tokens.Add(new SqlToken(plainStart, upto - plainStart, SqlTokenKind.Plain));
+ }
+ }
+
+ while (i < text.Length)
+ {
+ var ch = text[i];
+
+ // 치환 변수 <<...>> — 닫히지 않으면 SQL 에 그대로 남아 오류가 난다
+ if (ch == '<' && i + 1 < text.Length && text[i + 1] == '<')
+ {
+ var close = text.IndexOf(">>", i + 2, StringComparison.Ordinal);
+ FlushPlain(i);
+ if (close < 0)
+ {
+ tokens.Add(new SqlToken(i, text.Length - i, SqlTokenKind.Broken));
+ return tokens;
+ }
+ tokens.Add(new SqlToken(i, close + 2 - i, SqlTokenKind.Variable));
+ i = close + 2;
+ plainStart = i;
+ continue;
+ }
+
+ // 한 줄 주석
+ if (ch == '-' && i + 1 < text.Length && text[i + 1] == '-')
+ {
+ var lineEnd = text.IndexOf('\n', i);
+ var end = lineEnd < 0 ? text.Length : lineEnd;
+ FlushPlain(i);
+ tokens.Add(new SqlToken(i, end - i, SqlTokenKind.Comment));
+ i = end;
+ plainStart = i;
+ continue;
+ }
+
+ // 블록 주석
+ if (ch == '/' && i + 1 < text.Length && text[i + 1] == '*')
+ {
+ var close = text.IndexOf("*/", i + 2, StringComparison.Ordinal);
+ var end = close < 0 ? text.Length : close + 2;
+ FlushPlain(i);
+ tokens.Add(new SqlToken(i, end - i, SqlTokenKind.Comment));
+ i = end;
+ plainStart = i;
+ continue;
+ }
+
+ // 문자열 — Oracle 은 '' 로 작은따옴표를 이스케이프한다
+ if (ch == '\'')
+ {
+ var j = i + 1;
+ var closed = false;
+ while (j < text.Length)
+ {
+ if (text[j] == '\'')
+ {
+ if (j + 1 < text.Length && text[j + 1] == '\'')
+ {
+ j += 2;
+ continue;
+ }
+ closed = true;
+ j++;
+ break;
+ }
+ // 줄바꿈을 넘긴 문자열은 거의 항상 닫는 따옴표를 빠뜨린 것이다
+ if (text[j] == '\n')
+ {
+ break;
+ }
+ j++;
+ }
+ FlushPlain(i);
+ tokens.Add(new SqlToken(i, j - i, closed ? SqlTokenKind.Text : SqlTokenKind.Broken));
+ i = j;
+ plainStart = i;
+ continue;
+ }
+
+ // 숫자
+ if (char.IsDigit(ch) && (i == 0 || !IsWordChar(text[i - 1])))
+ {
+ var j = i;
+ while (j < text.Length && (char.IsDigit(text[j]) || text[j] == '.'))
+ {
+ j++;
+ }
+ FlushPlain(i);
+ tokens.Add(new SqlToken(i, j - i, SqlTokenKind.Number));
+ i = j;
+ plainStart = i;
+ continue;
+ }
+
+ // 낱말 — 예약어/함수만 골라낸다
+ if (IsWordStart(ch))
+ {
+ var j = i;
+ while (j < text.Length && IsWordChar(text[j]))
+ {
+ j++;
+ }
+ var word = text[i..j];
+ if (Keywords.Contains(word) || Functions.Contains(word))
+ {
+ FlushPlain(i);
+ tokens.Add(new SqlToken(i, j - i,
+ Keywords.Contains(word) ? SqlTokenKind.Keyword : SqlTokenKind.Function));
+ plainStart = j;
+ }
+ i = j;
+ continue;
+ }
+
+ i++;
+ }
+
+ FlushPlain(text.Length);
+ return tokens;
+ }
+
+ private static bool IsWordStart(char ch) => char.IsLetter(ch) || ch == '_';
+
+ private static bool IsWordChar(char ch) => char.IsLetterOrDigit(ch) || ch == '_' || ch == '$' || ch == '#';
+
+ ///
+ /// 텍스트에 든 치환 변수 토큰 전부 — 검증·요약용.
+ ///
+ public static IReadOnlyList VariablesIn(string text)
+ {
+ var found = new List();
+ foreach (var token in Tokenize(text))
+ {
+ if (token.Kind == SqlTokenKind.Variable)
+ {
+ found.Add(text.Substring(token.Start, token.Length));
+ }
+ }
+ return found;
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs b/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs
new file mode 100644
index 0000000..6aaba0f
--- /dev/null
+++ b/src/SheetMe.Designer/Controls/SqlHighlightLayer.cs
@@ -0,0 +1,221 @@
+using System.Globalization;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Media;
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Designer.Controls;
+
+///
+/// SQL 색칠 레이어 — 투명한 뒤에 같은 글자를 색을 입혀 그린다.
+///
+/// 왜 이 방식인가. 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 했다.
+/// RichTextBox 로 직접 칠하는 방법도 있지만 편집할 때마다 서식을 다시 입혀야 해서
+/// 실행취소 묶음이 깨지고 IME 조합 중 깜빡인다.
+/// 대신 편집은 평범한 TextBox 가 그대로 하고(캐럿·선택·실행취소·IME·스크롤 전부 공짜),
+/// 글자만 투명하게 해서 이 레이어가 뒤에서 같은 자리에 색을 입혀 그린다.
+///
+/// 자리를 맞추는 조건은 하나다 — 글꼴·크기·여백·줄바꿈 설정이 TextBox 와 완전히 같을 것.
+/// 그래서 폭·글꼴을 직접 읽어 쓰고, 줄바꿈 없음(NoWrap)을 전제로 한 줄씩 그린다.
+///
+public sealed class SqlHighlightLayer : FrameworkElement
+{
+ #region Member Fields
+ private TextBox? source;
+ private ScrollViewer? scroller;
+ #endregion
+
+ #region Properties
+ /// 색칠 대상 — 이 TextBox 의 글자를 뒤에서 다시 그린다
+ public TextBox? Source
+ {
+ get => source;
+ set
+ {
+ if (ReferenceEquals(source, value))
+ {
+ return;
+ }
+ Detach();
+ source = value;
+ Attach();
+ InvalidateVisual();
+ }
+ }
+ #endregion
+
+ #region Methods
+ private void Attach()
+ {
+ if (source is null)
+ {
+ return;
+ }
+ source.TextChanged += OnSourceChanged;
+ source.SizeChanged += OnSourceChanged;
+ source.Loaded += OnSourceLoaded;
+ HookScroller();
+ }
+
+ private void Detach()
+ {
+ if (source is not null)
+ {
+ source.TextChanged -= OnSourceChanged;
+ source.SizeChanged -= OnSourceChanged;
+ source.Loaded -= OnSourceLoaded;
+ }
+ if (scroller is not null)
+ {
+ scroller.ScrollChanged -= OnScrolled;
+ scroller = null;
+ }
+ }
+
+ private void OnSourceLoaded(object sender, RoutedEventArgs e)
+ {
+ HookScroller();
+ InvalidateVisual();
+ }
+
+ ///
+ /// TextBox 내부 ScrollViewer 를 찾아 스크롤을 따라간다.
+ /// 이걸 놓치면 스크롤한 순간 글자와 색이 어긋나 보인다.
+ ///
+ private void HookScroller()
+ {
+ if (source is null || scroller is not null)
+ {
+ return;
+ }
+ scroller = FindScrollViewer(source);
+ if (scroller is not null)
+ {
+ scroller.ScrollChanged += OnScrolled;
+ }
+ }
+
+ private static ScrollViewer? FindScrollViewer(DependencyObject root)
+ {
+ if (root is ScrollViewer found)
+ {
+ return found;
+ }
+ var count = VisualTreeHelper.GetChildrenCount(root);
+ for (var i = 0; i < count; i++)
+ {
+ if (FindScrollViewer(VisualTreeHelper.GetChild(root, i)) is { } child)
+ {
+ return child;
+ }
+ }
+ return null;
+ }
+
+ private void OnScrolled(object sender, ScrollChangedEventArgs e) => InvalidateVisual();
+
+ private void OnSourceChanged(object sender, EventArgs e) => InvalidateVisual();
+
+ /// 색 하나를 테마 자원에서 — 없으면 눈에 띄는 대체색(자원 누락을 조용히 넘기지 않는다)
+ private Brush BrushOf(string key)
+ => TryFindResource(key) as Brush ?? Brushes.Magenta;
+
+ private Brush BrushFor(SqlTokenKind kind) => kind switch
+ {
+ SqlTokenKind.Keyword => BrushOf("B.SqlKeyword"),
+ SqlTokenKind.Function => BrushOf("B.SqlFunction"),
+ SqlTokenKind.Number => BrushOf("B.SqlNumber"),
+ SqlTokenKind.Text => BrushOf("B.SqlText"),
+ SqlTokenKind.Comment => BrushOf("B.SqlComment"),
+ SqlTokenKind.Variable => BrushOf("B.SqlVariable"),
+ SqlTokenKind.Broken => BrushOf("B.Danger"),
+ _ => BrushOf("B.Ink"),
+ };
+
+ protected override void OnRender(DrawingContext dc)
+ {
+ if (source is null || source.Text.Length == 0)
+ {
+ return;
+ }
+
+ var text = source.Text;
+ var tokens = SqlTokenizer.Tokenize(text);
+ var typeface = new Typeface(source.FontFamily, source.FontStyle, source.FontWeight, source.FontStretch);
+ var dpi = VisualTreeHelper.GetDpi(this).PixelsPerDip;
+
+ // TextBox 의 내부 여백 + 스크롤 오프셋만큼 옮겨 그린다
+ var offsetX = source.Padding.Left + source.BorderThickness.Left - (scroller?.HorizontalOffset ?? 0);
+ var offsetY = source.Padding.Top + source.BorderThickness.Top - (scroller?.VerticalOffset ?? 0);
+
+ // 줄 높이는 실제 글꼴에서 잰다 — 상수로 두면 글꼴이 바뀔 때 어긋난다
+ var probe = new FormattedText("Ag", CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
+ typeface, source.FontSize, Brushes.Black, dpi);
+ var lineHeight = probe.Height;
+
+ var lineIndex = 0;
+ var lineStart = 0;
+ var height = ActualHeight;
+
+ while (lineStart <= text.Length)
+ {
+ var newline = text.IndexOf('\n', lineStart);
+ var lineEnd = newline < 0 ? text.Length : newline;
+ var y = offsetY + lineIndex * lineHeight;
+
+ // 화면 밖 줄은 그리지 않는다 — 긴 쿼리에서 매 입력마다 전부 그리면 눈에 띄게 느려진다
+ if (y > height)
+ {
+ break;
+ }
+ if (y + lineHeight >= 0)
+ {
+ DrawLine(dc, text, tokens, lineStart, lineEnd, typeface, dpi, offsetX, y);
+ }
+
+ if (newline < 0)
+ {
+ break;
+ }
+ lineStart = newline + 1;
+ lineIndex++;
+ }
+ }
+
+ ///
+ /// 한 줄을 조각별 색으로 그린다.
+ /// 조각 경계마다 를 새로 만들되, x 위치는 앞부분 전체를 다시 재서 잡는다 —
+ /// 폭을 누적하면 자간·힌팅 때문에 뒤로 갈수록 한두 픽셀씩 밀린다.
+ ///
+ private void DrawLine(DrawingContext dc, string text, IReadOnlyList tokens,
+ int lineStart, int lineEnd, Typeface typeface, double dpi, double offsetX, double y)
+ {
+ foreach (var token in tokens)
+ {
+ if (token.End <= lineStart || token.Start >= lineEnd)
+ {
+ continue;
+ }
+ var start = Math.Max(token.Start, lineStart);
+ var end = Math.Min(token.End, lineEnd);
+ if (end <= start)
+ {
+ continue;
+ }
+
+ var prefix = text[lineStart..start];
+ var x = offsetX;
+ if (prefix.Length > 0)
+ {
+ var measured = new FormattedText(prefix, CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
+ typeface, source!.FontSize, Brushes.Black, dpi);
+ x += measured.WidthIncludingTrailingWhitespace;
+ }
+
+ var piece = new FormattedText(text[start..end], CultureInfo.InvariantCulture, FlowDirection.LeftToRight,
+ typeface, source!.FontSize, BrushFor(token.Kind), dpi);
+ dc.DrawText(piece, new Point(x, y));
+ }
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Themes/Tokens.Dark.xaml b/src/SheetMe.Designer/Themes/Tokens.Dark.xaml
index fb5603c..e80731d 100644
--- a/src/SheetMe.Designer/Themes/Tokens.Dark.xaml
+++ b/src/SheetMe.Designer/Themes/Tokens.Dark.xaml
@@ -47,6 +47,14 @@
오류는 B.CloseHover 와 같은 값이라 다크 팔레트에 새 색이 늘지 않는다. -->
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Themes/Tokens.Light.xaml b/src/SheetMe.Designer/Themes/Tokens.Light.xaml
index 157648e..25ecd1b 100644
--- a/src/SheetMe.Designer/Themes/Tokens.Light.xaml
+++ b/src/SheetMe.Designer/Themes/Tokens.Light.xaml
@@ -81,6 +81,15 @@
-->
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
index 4442c55..17bef0e 100644
--- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
+++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml
@@ -1,16 +1,20 @@
+ Text="작성 시점에 <<변수>> 가 실제 값으로 치환됩니다. 변수는 클래스 전체 이름을 포함해야 하므로 우측 목록에서 삽입하세요 — 임의로 줄여 쓰면 치환되지 않고 SQL 에 그대로 남아 오류가 납니다."/>
-
-
+
+
+
@@ -18,32 +22,70 @@
-
+
-
-
+
+
+
+
+
+
+
-
-
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
-
+
+
+ HorizontalContentAlignment="Stretch"
+ ScrollViewer.HorizontalScrollBarVisibility="Disabled"
+ AutomationProperties.Name="치환 변수 목록">
-
-
+
+
diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
index 3ed9493..6c9d6a0 100644
--- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
+++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs
@@ -1,22 +1,31 @@
-using System.ComponentModel;
+using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
+using System.Windows.Input;
using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Views;
///
-/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 큰 SQL 편집 영역 + 치환 변수 삽입.
+/// 데이터소스(MDataTable) 쿼리 전용 편집기 — 구문 강조 + 편집 보조 + 치환 변수 삽입.
+///
/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery.
/// 토큰 접두어는 클래스 전체 이름이어야 하며 런타임이 GetType.FullName 과 완전일치로 비교한다
/// (ucLoadSheetBase.vb:7619-7631 이 형을 고정). 목록은 참조.
///
+/// 색칠은 가 뒤에서 하고 편집은 투명 TextBox 가 그대로 한다 —
+/// 서드파티 편집기 컴포넌트를 배포본에 넣지 않기로 해서 직접 만든 구성이다.
+///
/// 큰 다중행 편집 영역이 필요한 다른 용도(상용구 문구)에도 재사용한다 —
-/// 그 경우 showVariables: false 로 SQL 변수 패널을 숨긴다.
+/// 그 경우 showVariables: false 로 SQL 변수 패널을 숨기고 색칠도 끈다.
///
public partial class QueryEditorWindow : Window
{
+ #region Member Fields
+ private readonly bool isSql;
+ #endregion
+
#region Properties
/// 편집 결과 텍스트 — 확인 시 채워짐
public string QueryText { get; private set; } = string.Empty;
@@ -29,19 +38,21 @@ public partial class QueryEditorWindow : Window
public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true)
{
InitializeComponent();
+ isSql = showVariables;
SqlBox.Text = initialQuery;
if (showVariables)
{
Title = $"쿼리 편집 — {ownerLabel}";
- var view = new CollectionViewSource { Source = LegacyQueryVariableCatalog.All };
- view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
- VariableList.ItemsSource = view.View;
+ Highlight.Source = SqlBox;
+ ApplyVariableFilter(string.Empty);
}
else
{
+ // SQL 이 아닌 텍스트에 SQL 색을 칠하면 예약어와 겹치는 낱말이 엉뚱하게 물든다
Title = ownerLabel;
HintText.Text = "내용을 편집한 뒤 확인을 누르세요.";
+ SqlBox.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink");
VariablePane.Visibility = Visibility.Collapsed;
SplitterColumn.Width = new GridLength(0);
VariableColumn.Width = new GridLength(0);
@@ -51,15 +62,267 @@ public partial class QueryEditorWindow : Window
{
SqlBox.Focus();
SqlBox.CaretIndex = SqlBox.Text.Length;
+ Refresh();
};
}
#endregion
- #region Methods
- private void OnSqlChanged(object sender, TextChangedEventArgs e)
- => LengthText.Text = $"{SqlBox.Text.Length:N0}자";
+ #region Methods - Editing
+ private void OnSqlChanged(object sender, TextChangedEventArgs e) => Refresh();
- private void OnInsertVariable(object sender, System.Windows.Input.MouseButtonEventArgs e)
+ private void Refresh()
+ {
+ var text = SqlBox.Text;
+ LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
+ UpdateLineNumbers(text);
+ UpdateProblems(text);
+ }
+
+ private static int CountLines(string text)
+ {
+ var lines = 1;
+ foreach (var ch in text)
+ {
+ if (ch == '\n')
+ {
+ lines++;
+ }
+ }
+ return lines;
+ }
+
+ private void UpdateLineNumbers(string text)
+ {
+ var builder = new StringBuilder();
+ var count = CountLines(text);
+ for (var i = 1; i <= count; i++)
+ {
+ builder.Append(i).Append('\n');
+ }
+ LineNumbers.Text = builder.ToString();
+ }
+
+ ///
+ /// 저장 전에 눈에 띄어야 하는 것만 알린다 — 닫히지 않은 조각과 축약된 치환 변수.
+ ///
+ /// 런타임은 치환에 실패해도 빈 catch 로 삼켜 화면에 아무 표시가 없다.
+ /// 여기서 잡지 못하면 배포된 뒤 임상 화면에서 빈칸으로만 드러난다.
+ ///
+ private void UpdateProblems(string text)
+ {
+ if (!isSql)
+ {
+ ProblemText.Text = string.Empty;
+ return;
+ }
+
+ var problems = new List();
+ if (SqlTokenizer.Tokenize(text).Any(t => t.Kind == SqlTokenKind.Broken))
+ {
+ problems.Add("닫히지 않은 따옴표 또는 << >>");
+ }
+
+ var known = LegacyQueryVariableCatalog.All.Select(v => v.Token).ToHashSet(StringComparer.Ordinal);
+ var unknown = SqlTokenizer.VariablesIn(text)
+ .Where(v => !known.Contains(v))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ if (unknown.Count > 0)
+ {
+ problems.Add($"목록에 없는 변수 {unknown.Count}건: {unknown[0]}");
+ }
+
+ ProblemText.Text = problems.Count == 0 ? string.Empty : string.Join(" · ", problems);
+ }
+
+ ///
+ /// 편집 보조 — Tab 들여쓰기, Enter 자동 들여쓰기, Ctrl+/ 주석 토글.
+ /// TextBox 가 기본으로 주지 않는 것 중 SQL 을 칠 때 손이 가장 많이 가는 것들만 넣었다.
+ ///
+ private void OnSqlKeyDown(object sender, KeyEventArgs e)
+ {
+ var ctrl = (Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control;
+ var shift = (Keyboard.Modifiers & ModifierKeys.Shift) == ModifierKeys.Shift;
+
+ if (e.Key == Key.Tab)
+ {
+ IndentSelection(outdent: shift);
+ e.Handled = true;
+ return;
+ }
+ if (ctrl && (e.Key == Key.Oem2 || e.Key == Key.Divide))
+ {
+ ToggleComment();
+ e.Handled = true;
+ return;
+ }
+ if (e.Key == Key.Return && !ctrl)
+ {
+ AutoIndentNewLine();
+ e.Handled = true;
+ }
+ }
+
+ /// 현재 줄의 시작 위치
+ private int LineStartOf(int index)
+ {
+ var text = SqlBox.Text;
+ var start = index;
+ while (start > 0 && text[start - 1] != '\n')
+ {
+ start--;
+ }
+ return start;
+ }
+
+ /// Enter — 앞 줄의 들여쓰기를 그대로 물려준다
+ private void AutoIndentNewLine()
+ {
+ var text = SqlBox.Text;
+ var caret = SqlBox.SelectionStart;
+ var lineStart = LineStartOf(caret);
+ var indent = 0;
+ while (lineStart + indent < text.Length && lineStart + indent < caret
+ && (text[lineStart + indent] == ' ' || text[lineStart + indent] == '\t'))
+ {
+ indent++;
+ }
+ var insert = "\n" + text.Substring(lineStart, indent);
+ var selectionLength = SqlBox.SelectionLength;
+ SqlBox.Text = text.Remove(caret, selectionLength).Insert(caret, insert);
+ SqlBox.CaretIndex = caret + insert.Length;
+ }
+
+ /// Tab / Shift+Tab — 선택한 줄 전체를 들여쓰거나 내어쓴다
+ private void IndentSelection(bool outdent)
+ {
+ const string Unit = " ";
+ var text = SqlBox.Text;
+ var start = SqlBox.SelectionStart;
+ var length = SqlBox.SelectionLength;
+
+ // 선택이 없으면 커서 자리에 공백만 넣는다(내어쓰기는 줄 단위로 동작)
+ if (length == 0 && !outdent)
+ {
+ SqlBox.Text = text.Insert(start, Unit);
+ SqlBox.CaretIndex = start + Unit.Length;
+ return;
+ }
+
+ var blockStart = LineStartOf(start);
+ var blockEnd = start + length;
+ while (blockEnd < text.Length && text[blockEnd] != '\n')
+ {
+ blockEnd++;
+ }
+
+ var lines = text[blockStart..blockEnd].Split('\n');
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (outdent)
+ {
+ if (lines[i].StartsWith(Unit, StringComparison.Ordinal))
+ {
+ lines[i] = lines[i][Unit.Length..];
+ }
+ else
+ {
+ lines[i] = lines[i].TrimStart(' ', '\t');
+ }
+ }
+ else
+ {
+ lines[i] = Unit + lines[i];
+ }
+ }
+
+ var replaced = string.Join('\n', lines);
+ SqlBox.Text = text[..blockStart] + replaced + text[blockEnd..];
+ SqlBox.SelectionStart = blockStart;
+ SqlBox.SelectionLength = replaced.Length;
+ }
+
+ /// Ctrl+/ — 선택한 줄에 -- 를 붙이거나 뗀다
+ private void ToggleComment()
+ {
+ const string Mark = "-- ";
+ var text = SqlBox.Text;
+ var start = SqlBox.SelectionStart;
+ var blockStart = LineStartOf(start);
+ var blockEnd = start + SqlBox.SelectionLength;
+ while (blockEnd < text.Length && text[blockEnd] != '\n')
+ {
+ blockEnd++;
+ }
+
+ var lines = text[blockStart..blockEnd].Split('\n');
+ // 한 줄이라도 주석이 아니면 전부 붙인다 — 섞인 상태에서 토글이 예측 가능해진다
+ var allCommented = lines.All(l => l.TrimStart().StartsWith("--", StringComparison.Ordinal));
+ for (var i = 0; i < lines.Length; i++)
+ {
+ if (allCommented)
+ {
+ var trimmed = lines[i].TrimStart();
+ var pad = lines[i][..(lines[i].Length - trimmed.Length)];
+ trimmed = trimmed[2..];
+ if (trimmed.StartsWith(' '))
+ {
+ trimmed = trimmed[1..];
+ }
+ lines[i] = pad + trimmed;
+ }
+ else
+ {
+ lines[i] = Mark + lines[i];
+ }
+ }
+
+ var replaced = string.Join('\n', lines);
+ SqlBox.Text = text[..blockStart] + replaced + text[blockEnd..];
+ SqlBox.SelectionStart = blockStart;
+ SqlBox.SelectionLength = replaced.Length;
+ }
+ #endregion
+
+ #region Methods - Variables
+ private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
+ => ApplyVariableFilter(VariableSearch.Text);
+
+ /// 이름·설명·초성으로 좁힌다 — 목록이 길어 눈으로 훑기 어렵다
+ private void ApplyVariableFilter(string keyword)
+ {
+ var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ var matched = tokens.Length == 0
+ ? LegacyQueryVariableCatalog.All
+ : LegacyQueryVariableCatalog.All
+ .Where(v => tokens.All(t =>
+ TagSearch.Matches(v.Description, t) || TagSearch.Matches(v.Token, t)))
+ .ToList();
+
+ var view = new CollectionViewSource { Source = matched };
+ view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group)));
+ VariableList.ItemsSource = view.View;
+ VariableHint.Text = matched.Count == LegacyQueryVariableCatalog.All.Count
+ ? $"{matched.Count}개 · 더블클릭 또는 Enter 로 삽입합니다."
+ : $"{matched.Count}/{LegacyQueryVariableCatalog.All.Count}개 · 더블클릭 또는 Enter 로 삽입합니다.";
+ if (matched.Count > 0)
+ {
+ VariableList.SelectedIndex = 0;
+ }
+ }
+
+ private void OnVariableListKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Return)
+ {
+ InsertSelectedVariable();
+ e.Handled = true;
+ }
+ }
+
+ private void OnInsertVariable(object sender, MouseButtonEventArgs e) => InsertSelectedVariable();
+
+ private void InsertSelectedVariable()
{
if (VariableList.SelectedItem is not QueryVariable variable)
{
@@ -70,7 +333,9 @@ public partial class QueryEditorWindow : Window
SqlBox.CaretIndex = caret + variable.Token.Length;
SqlBox.Focus();
}
+ #endregion
+ #region Methods - Confirm
private void OnConfirm(object sender, RoutedEventArgs e)
{
QueryText = SqlBox.Text;
diff --git a/tests/SheetMe.Core.Tests/SqlTokenizerTests.cs b/tests/SheetMe.Core.Tests/SqlTokenizerTests.cs
new file mode 100644
index 0000000..2aa55bb
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/SqlTokenizerTests.cs
@@ -0,0 +1,140 @@
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Core.Tests;
+
+///
+/// SQL 조각내기 테스트.
+///
+/// 색칠이 어긋나면 편집기가 거짓말을 한다 — 특히 치환 변수와 '깨진 조각' 판정이 그렇다.
+/// 이 쿼리는 EMR 런타임에서 돌고 실패해도 빈 catch 가 삼키므로,
+/// 편집기에서 눈에 띄지 않으면 배포된 뒤에야 드러난다.
+///
+[TestClass]
+public sealed class SqlTokenizerTests
+{
+ private static SqlTokenKind KindAt(string text, int index)
+ {
+ foreach (var token in SqlTokenizer.Tokenize(text))
+ {
+ if (index >= token.Start && index < token.End)
+ {
+ return token.Kind;
+ }
+ }
+ return SqlTokenKind.Plain;
+ }
+
+ private static string PieceOf(string text, SqlTokenKind kind)
+ {
+ foreach (var token in SqlTokenizer.Tokenize(text))
+ {
+ if (token.Kind == kind)
+ {
+ return text.Substring(token.Start, token.Length);
+ }
+ }
+ return string.Empty;
+ }
+
+ /// 조각은 겹치지 않고 전 구간을 덮어야 한다 — 빈틈이 있으면 그 글자가 안 그려진다
+ [TestMethod]
+ public void Tokenize_CoversEveryCharacterExactlyOnce()
+ {
+ const string sql = "SELECT NVL(a,0) /* c */ FROM t WHERE x='y' -- 끝\nAND z=1";
+
+ var tokens = SqlTokenizer.Tokenize(sql);
+
+ var position = 0;
+ foreach (var token in tokens)
+ {
+ Assert.AreEqual(position, token.Start, $"빈틈 또는 겹침: {token.Start} 에서");
+ position = token.End;
+ }
+ Assert.AreEqual(sql.Length, position, "마지막까지 덮지 못했습니다");
+ }
+
+ [TestMethod]
+ public void Tokenize_MarksKeywordsAndFunctions()
+ {
+ const string sql = "SELECT NVL(x,0) FROM DUAL";
+
+ Assert.AreEqual(SqlTokenKind.Keyword, KindAt(sql, 0), "SELECT");
+ Assert.AreEqual(SqlTokenKind.Function, KindAt(sql, 7), "NVL");
+ Assert.AreEqual(SqlTokenKind.Keyword, KindAt(sql, 16), "FROM");
+ }
+
+ /// 예약어는 대소문자를 가리지 않는다 — 실서식에 소문자 쿼리가 섞여 있다
+ [TestMethod]
+ public void Tokenize_KeywordsAreCaseInsensitive()
+ => Assert.AreEqual(SqlTokenKind.Keyword, KindAt("select 1 from dual", 0));
+
+ /// 식별자 안에 든 예약어 조각은 예약어가 아니다(SELECTED 를 SELECT 로 칠하면 안 된다)
+ [TestMethod]
+ public void Tokenize_DoesNotMatchKeywordInsideIdentifier()
+ => Assert.AreEqual(SqlTokenKind.Plain, KindAt("SELECTED_FLAG", 0));
+
+ [TestMethod]
+ public void Tokenize_FindsStringsAndComments()
+ {
+ Assert.AreEqual("'가나'", PieceOf("WHERE nam='가나'", SqlTokenKind.Text));
+ Assert.AreEqual("-- 설명", PieceOf("SELECT 1 -- 설명\nFROM t", SqlTokenKind.Comment));
+ Assert.AreEqual("/* 블록 */", PieceOf("SELECT /* 블록 */ 1", SqlTokenKind.Comment));
+ }
+
+ /// Oracle 은 '' 로 작은따옴표를 이스케이프한다 — 거기서 문자열이 끊기면 안 된다
+ [TestMethod]
+ public void Tokenize_HandlesDoubledQuoteEscape()
+ => Assert.AreEqual("'it''s'", PieceOf("WHERE x='it''s'", SqlTokenKind.Text));
+
+ ///
+ /// 치환 변수는 이 편집기의 핵심 조각이다 — 통째로 하나로 잡혀야 한다.
+ /// 안쪽 점·대문자 때문에 여러 조각으로 쪼개지면 색이 얼룩진다.
+ ///
+ [TestMethod]
+ public void Tokenize_TreatsSubstitutionVariableAsOnePiece()
+ {
+ const string sql = "WHERE ChtNum = <>";
+
+ Assert.AreEqual("<>",
+ PieceOf(sql, SqlTokenKind.Variable));
+ }
+
+ [TestMethod]
+ public void VariablesIn_ListsEveryToken()
+ {
+ const string sql = "SELECT <> , <> FROM t";
+
+ CollectionAssert.AreEqual(new[] { "<>", "<>" }, SqlTokenizer.VariablesIn(sql).ToArray());
+ }
+
+ ///
+ /// 닫히지 않은 조각은 Broken 이다. 그대로 저장하면 런타임이 조용히 실패하므로
+ /// 편집기에서 즉시 눈에 띄어야 한다.
+ ///
+ [DataTestMethod]
+ [DataRow("WHERE x = < t.Kind == SqlTokenKind.Broken));
+ // 다음 줄의 AND 는 정상 예약어로 남아야 한다
+ Assert.AreEqual(SqlTokenKind.Keyword, KindAt(sql, sql.IndexOf("AND", StringComparison.Ordinal)));
+ }
+
+ [TestMethod]
+ public void Tokenize_EmptyTextYieldsNothing()
+ => Assert.AreEqual(0, SqlTokenizer.Tokenize(string.Empty).Count);
+}