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 에 그대로 남아 오류가 납니다."/> - -