Skip to main content

sqlmodel_console/renderables/
ddl_display.rs

1//! DDL (Data Definition Language) syntax highlighting for schema output.
2//!
3//! Provides syntax highlighting for CREATE TABLE, CREATE INDEX, ALTER TABLE,
4//! and other DDL statements with dialect-specific keyword support.
5//!
6//! # Example
7//!
8//! ```rust
9//! use sqlmodel_console::renderables::{DdlDisplay, SqlDialect};
10//! use sqlmodel_console::Theme;
11//!
12//! let ddl = "CREATE TABLE users (
13//!     id SERIAL PRIMARY KEY,
14//!     name TEXT NOT NULL,
15//!     email TEXT UNIQUE
16//! );";
17//!
18//! let display = DdlDisplay::new(ddl)
19//!     .dialect(SqlDialect::PostgreSQL)
20//!     .line_numbers(true);
21//!
22//! // Rich mode with syntax highlighting
23//! println!("{}", display.render(80));
24//!
25//! // Plain mode for agents
26//! println!("{}", display.render_plain());
27//! ```
28
29use crate::renderables::sql_syntax::{SqlHighlighter, SqlSegment, SqlToken};
30use crate::theme::Theme;
31
32/// SQL dialect for DDL generation.
33///
34/// Different databases have different DDL syntax and keywords.
35/// This enum determines which dialect-specific keywords to highlight.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum SqlDialect {
38    /// PostgreSQL dialect with SERIAL, BIGSERIAL, array types, etc.
39    #[default]
40    PostgreSQL,
41    /// SQLite dialect with AUTOINCREMENT, special type handling.
42    SQLite,
43    /// MySQL dialect with AUTO_INCREMENT, ENGINE clause, etc.
44    MySQL,
45}
46
47impl SqlDialect {
48    /// Get dialect-specific DDL keywords.
49    #[must_use]
50    pub fn ddl_keywords(&self) -> &'static [&'static str] {
51        match self {
52            Self::PostgreSQL => &[
53                // PostgreSQL-specific
54                "SERIAL",
55                "BIGSERIAL",
56                "SMALLSERIAL",
57                "RETURNING",
58                "INHERITS",
59                "PARTITION",
60                "TABLESPACE",
61                "OWNED",
62                "STORAGE",
63                "EXCLUDE",
64                "DEFERRABLE",
65                "INITIALLY",
66                "DEFERRED",
67                "IMMEDIATE",
68                "CONCURRENTLY",
69            ],
70            Self::SQLite => &[
71                // SQLite-specific
72                "AUTOINCREMENT",
73                "WITHOUT",
74                "ROWID",
75                "STRICT",
76                "VIRTUAL",
77                "USING",
78                "FTS5",
79                "RTREE",
80            ],
81            Self::MySQL => &[
82                // MySQL-specific
83                "AUTO_INCREMENT",
84                "ENGINE",
85                "CHARSET",
86                "COLLATE",
87                "ROW_FORMAT",
88                "COMMENT",
89                "PARTITION",
90                "PARTITIONS",
91                "SUBPARTITION",
92                "ALGORITHM",
93                "LOCK",
94                "UNSIGNED",
95                "ZEROFILL",
96            ],
97        }
98    }
99
100    /// Get the dialect name as a string.
101    #[must_use]
102    pub fn as_str(&self) -> &'static str {
103        match self {
104            Self::PostgreSQL => "PostgreSQL",
105            Self::SQLite => "SQLite",
106            Self::MySQL => "MySQL",
107        }
108    }
109}
110
111impl std::fmt::Display for SqlDialect {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        write!(f, "{}", self.as_str())
114    }
115}
116
117/// Kind of change for diff highlighting.
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum ChangeKind {
120    /// Line was added.
121    Added,
122    /// Line was removed.
123    Removed,
124    /// Line was modified.
125    Modified,
126}
127
128/// A region of changed lines for diff highlighting.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ChangeRegion {
131    /// Starting line number (1-indexed).
132    pub start_line: usize,
133    /// Ending line number (1-indexed, inclusive).
134    pub end_line: usize,
135    /// Kind of change.
136    pub kind: ChangeKind,
137}
138
139impl ChangeRegion {
140    /// Create a new change region.
141    #[must_use]
142    pub fn new(start_line: usize, end_line: usize, kind: ChangeKind) -> Self {
143        Self {
144            start_line,
145            end_line,
146            kind,
147        }
148    }
149
150    /// Check if a line number is within this region.
151    #[must_use]
152    pub fn contains_line(&self, line: usize) -> bool {
153        line >= self.start_line && line <= self.end_line
154    }
155}
156
157/// DDL display for schema output with syntax highlighting.
158///
159/// Displays DDL statements with optional line numbers, dialect-specific
160/// highlighting, and change region highlighting for migration diffs.
161#[derive(Debug, Clone)]
162pub struct DdlDisplay {
163    /// The DDL SQL statement(s).
164    sql: String,
165    /// SQL dialect for syntax highlighting.
166    dialect: SqlDialect,
167    /// Theme for coloring.
168    theme: Theme,
169    /// Whether to show line numbers.
170    line_numbers: bool,
171    /// Regions to highlight (for diffs).
172    change_regions: Vec<ChangeRegion>,
173}
174
175impl DdlDisplay {
176    /// Create a new DDL display from SQL.
177    ///
178    /// # Example
179    ///
180    /// ```rust
181    /// use sqlmodel_console::renderables::DdlDisplay;
182    ///
183    /// let display = DdlDisplay::new("CREATE TABLE users (id INT);");
184    /// ```
185    #[must_use]
186    pub fn new(sql: impl Into<String>) -> Self {
187        Self {
188            sql: sql.into(),
189            dialect: SqlDialect::default(),
190            theme: Theme::default(),
191            line_numbers: false,
192            change_regions: Vec::new(),
193        }
194    }
195
196    /// Set the SQL dialect for highlighting.
197    #[must_use]
198    pub fn dialect(mut self, dialect: SqlDialect) -> Self {
199        self.dialect = dialect;
200        self
201    }
202
203    /// Set whether to show line numbers.
204    #[must_use]
205    pub fn line_numbers(mut self, show: bool) -> Self {
206        self.line_numbers = show;
207        self
208    }
209
210    /// Set the theme for coloring.
211    #[must_use]
212    pub fn theme(mut self, theme: Theme) -> Self {
213        self.theme = theme;
214        self
215    }
216
217    /// Add change regions for diff highlighting.
218    #[must_use]
219    pub fn highlight_changes(mut self, regions: Vec<ChangeRegion>) -> Self {
220        self.change_regions = regions;
221        self
222    }
223
224    /// Add a single change region.
225    #[must_use]
226    pub fn add_change(mut self, region: ChangeRegion) -> Self {
227        self.change_regions.push(region);
228        self
229    }
230
231    /// Get the SQL content.
232    #[must_use]
233    pub fn sql(&self) -> &str {
234        &self.sql
235    }
236
237    /// Get the dialect.
238    #[must_use]
239    pub fn get_dialect(&self) -> SqlDialect {
240        self.dialect
241    }
242
243    /// Get whether line numbers are shown.
244    #[must_use]
245    pub fn shows_line_numbers(&self) -> bool {
246        self.line_numbers
247    }
248
249    /// Get the change regions.
250    #[must_use]
251    pub fn change_regions(&self) -> &[ChangeRegion] {
252        &self.change_regions
253    }
254
255    /// Render as plain text (no ANSI codes).
256    ///
257    /// Returns the SQL with optional line numbers, suitable for
258    /// agent consumption or non-TTY output.
259    ///
260    /// # Example
261    ///
262    /// ```rust
263    /// use sqlmodel_console::renderables::DdlDisplay;
264    ///
265    /// let display = DdlDisplay::new("SELECT 1;").line_numbers(true);
266    /// let plain = display.render_plain();
267    /// assert!(plain.contains("1 |"));
268    /// ```
269    #[must_use]
270    pub fn render_plain(&self) -> String {
271        let lines: Vec<&str> = self.sql.lines().collect();
272
273        if self.line_numbers {
274            let max_line_num = lines.len();
275            let width = max_line_num.to_string().len();
276
277            lines
278                .iter()
279                .enumerate()
280                .map(|(i, line)| format!("{:>width$} | {}", i + 1, line, width = width))
281                .collect::<Vec<_>>()
282                .join("\n")
283        } else {
284            self.sql.clone()
285        }
286    }
287
288    /// Render with syntax highlighting (ANSI codes).
289    ///
290    /// Returns the DDL with syntax highlighting, optional line numbers,
291    /// and change region highlighting.
292    #[must_use]
293    pub fn render(&self, _width: usize) -> String {
294        let highlighter = SqlHighlighter::with_theme(self.theme.clone());
295        let lines: Vec<&str> = self.sql.lines().collect();
296        let max_line_num = lines.len();
297        let line_width = max_line_num.to_string().len();
298
299        let reset = "\x1b[0m";
300        let dim = "\x1b[2m";
301
302        let mut result = Vec::new();
303
304        for (i, line) in lines.iter().enumerate() {
305            let line_num = i + 1;
306            let mut line_output = String::new();
307
308            // Add line number if enabled
309            if self.line_numbers {
310                let line_num_str = format!("{:>width$}", line_num, width = line_width);
311                line_output.push_str(&format!("{dim}{line_num_str} │{reset} "));
312            }
313
314            // Check for change region background
315            let change_bg = self.get_change_background(line_num);
316
317            // Apply change background if present
318            if let Some(bg) = &change_bg {
319                line_output.push_str(bg);
320            }
321
322            // Highlight the line with dialect-aware highlighting
323            let styled_line = self.highlight_line(line, &highlighter);
324            line_output.push_str(&styled_line);
325
326            // Reset at end of line if we had a background
327            if change_bg.is_some() {
328                line_output.push_str(reset);
329            }
330
331            result.push(line_output);
332        }
333
334        result.join("\n")
335    }
336
337    /// Highlight a single line with dialect awareness.
338    fn highlight_line(&self, line: &str, highlighter: &SqlHighlighter) -> String {
339        let segments = highlighter.tokenize(line);
340        let reset = "\x1b[0m";
341
342        segments
343            .iter()
344            .map(|seg| self.colorize_segment(seg))
345            .collect::<String>()
346            + reset
347    }
348
349    /// Colorize a segment with dialect-specific keyword detection.
350    fn colorize_segment(&self, seg: &SqlSegment) -> String {
351        let reset = "\x1b[0m";
352
353        // Check if this is a dialect-specific keyword
354        if seg.token == SqlToken::Identifier {
355            let upper = seg.text.to_uppercase();
356            let dialect_keywords = self.dialect.ddl_keywords();
357            if dialect_keywords.contains(&upper.as_str()) {
358                // Highlight as keyword
359                let color = self.theme.sql_keyword.color_code();
360                return format!("{color}{}{reset}", seg.text);
361            }
362        }
363
364        // Use standard coloring
365        let color = match seg.token {
366            SqlToken::Keyword => self.theme.sql_keyword.color_code(),
367            SqlToken::String => self.theme.sql_string.color_code(),
368            SqlToken::Number => self.theme.sql_number.color_code(),
369            SqlToken::Comment => self.theme.sql_comment.color_code(),
370            SqlToken::Operator => self.theme.sql_operator.color_code(),
371            SqlToken::Identifier => self.theme.sql_identifier.color_code(),
372            SqlToken::Parameter => self.theme.info.color_code(),
373            SqlToken::Punctuation | SqlToken::Whitespace => String::new(),
374        };
375
376        if color.is_empty() {
377            seg.text.clone()
378        } else {
379            format!("{color}{}{reset}", seg.text)
380        }
381    }
382
383    /// Get the background color for a line if it's in a change region.
384    fn get_change_background(&self, line: usize) -> Option<String> {
385        for region in &self.change_regions {
386            if region.contains_line(line) {
387                return Some(match region.kind {
388                    ChangeKind::Added => "\x1b[48;2;0;80;0m".to_string(), // Dark green bg
389                    ChangeKind::Removed => "\x1b[48;2;80;0;0m".to_string(), // Dark red bg
390                    ChangeKind::Modified => "\x1b[48;2;80;80;0m".to_string(), // Dark yellow bg
391                });
392            }
393        }
394        None
395    }
396}
397
398impl Default for DdlDisplay {
399    fn default() -> Self {
400        Self::new("")
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn test_ddl_display_creation() {
410        let ddl = DdlDisplay::new("CREATE TABLE users (id INT);");
411        assert_eq!(ddl.sql(), "CREATE TABLE users (id INT);");
412        assert_eq!(ddl.get_dialect(), SqlDialect::PostgreSQL);
413        assert!(!ddl.shows_line_numbers());
414    }
415
416    #[test]
417    fn test_ddl_display_postgres_dialect() {
418        let ddl = DdlDisplay::new("CREATE TABLE users (id SERIAL PRIMARY KEY);")
419            .dialect(SqlDialect::PostgreSQL);
420        assert_eq!(ddl.get_dialect(), SqlDialect::PostgreSQL);
421
422        // SERIAL should be recognized as a dialect keyword
423        let keywords = ddl.get_dialect().ddl_keywords();
424        assert!(keywords.contains(&"SERIAL"));
425        assert!(keywords.contains(&"BIGSERIAL"));
426    }
427
428    #[test]
429    fn test_ddl_display_sqlite_dialect() {
430        let ddl = DdlDisplay::new("CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT);")
431            .dialect(SqlDialect::SQLite);
432        assert_eq!(ddl.get_dialect(), SqlDialect::SQLite);
433
434        // AUTOINCREMENT should be recognized
435        let keywords = ddl.get_dialect().ddl_keywords();
436        assert!(keywords.contains(&"AUTOINCREMENT"));
437    }
438
439    #[test]
440    fn test_ddl_display_mysql_dialect() {
441        let ddl = DdlDisplay::new(
442            "CREATE TABLE users (id INT AUTO_INCREMENT PRIMARY KEY) ENGINE=InnoDB;",
443        )
444        .dialect(SqlDialect::MySQL);
445        assert_eq!(ddl.get_dialect(), SqlDialect::MySQL);
446
447        // AUTO_INCREMENT and ENGINE should be recognized
448        let keywords = ddl.get_dialect().ddl_keywords();
449        assert!(keywords.contains(&"AUTO_INCREMENT"));
450        assert!(keywords.contains(&"ENGINE"));
451    }
452
453    #[test]
454    fn test_ddl_display_line_numbers() {
455        let ddl = DdlDisplay::new("CREATE TABLE users (\n    id INT\n);").line_numbers(true);
456        assert!(ddl.shows_line_numbers());
457    }
458
459    #[test]
460    fn test_ddl_display_render_plain() {
461        let ddl = DdlDisplay::new("SELECT 1;\nSELECT 2;");
462        let plain = ddl.render_plain();
463        assert_eq!(plain, "SELECT 1;\nSELECT 2;");
464    }
465
466    #[test]
467    fn test_ddl_display_render_plain_with_line_numbers() {
468        let ddl = DdlDisplay::new("SELECT 1;\nSELECT 2;").line_numbers(true);
469        let plain = ddl.render_plain();
470        assert!(plain.contains("1 | SELECT 1;"));
471        assert!(plain.contains("2 | SELECT 2;"));
472    }
473
474    #[test]
475    fn test_ddl_display_render_rich() {
476        let ddl = DdlDisplay::new("SELECT 1;");
477        let rich = ddl.render(80);
478        // Should contain ANSI escape codes
479        assert!(rich.contains('\x1b'));
480        // Should contain the SQL
481        assert!(rich.contains("SELECT"));
482        assert!(rich.contains('1'));
483    }
484
485    #[test]
486    fn test_ddl_display_multi_statement() {
487        let ddl =
488            DdlDisplay::new("CREATE TABLE users (id INT);\nCREATE INDEX idx_users ON users(id);");
489        let plain = ddl.render_plain();
490        assert!(plain.contains("CREATE TABLE"));
491        assert!(plain.contains("CREATE INDEX"));
492    }
493
494    #[test]
495    fn test_ddl_display_with_comments() {
496        let ddl = DdlDisplay::new("-- This is a comment\nSELECT 1;");
497        let plain = ddl.render_plain();
498        assert!(plain.contains("-- This is a comment"));
499        assert!(plain.contains("SELECT 1;"));
500    }
501
502    #[test]
503    fn test_ddl_display_change_highlighting() {
504        let ddl = DdlDisplay::new("Line 1\nLine 2\nLine 3")
505            .highlight_changes(vec![ChangeRegion::new(2, 2, ChangeKind::Added)]);
506        assert_eq!(ddl.change_regions().len(), 1);
507        assert!(ddl.change_regions()[0].contains_line(2));
508        assert!(!ddl.change_regions()[0].contains_line(1));
509    }
510
511    #[test]
512    fn test_change_region_contains_line() {
513        let region = ChangeRegion::new(5, 10, ChangeKind::Modified);
514        assert!(!region.contains_line(4));
515        assert!(region.contains_line(5));
516        assert!(region.contains_line(7));
517        assert!(region.contains_line(10));
518        assert!(!region.contains_line(11));
519    }
520
521    #[test]
522    fn test_highlight_create_table() {
523        let ddl = DdlDisplay::new("CREATE TABLE users (id INT);");
524        let rich = ddl.render(80);
525        // CREATE and TABLE should be highlighted as keywords
526        assert!(rich.contains("CREATE"));
527        assert!(rich.contains("TABLE"));
528    }
529
530    #[test]
531    fn test_highlight_alter_table() {
532        let ddl = DdlDisplay::new("ALTER TABLE users ADD COLUMN name TEXT;");
533        let rich = ddl.render(80);
534        assert!(rich.contains("ALTER"));
535        assert!(rich.contains("TABLE"));
536        assert!(rich.contains("ADD"));
537    }
538
539    #[test]
540    fn test_highlight_drop_table() {
541        let ddl = DdlDisplay::new("DROP TABLE IF EXISTS users;");
542        let rich = ddl.render(80);
543        assert!(rich.contains("DROP"));
544        assert!(rich.contains("TABLE"));
545        assert!(rich.contains("IF"));
546        assert!(rich.contains("EXISTS"));
547    }
548
549    #[test]
550    fn test_highlight_create_index() {
551        let ddl = DdlDisplay::new("CREATE INDEX idx_name ON users (name);");
552        let rich = ddl.render(80);
553        assert!(rich.contains("CREATE"));
554        assert!(rich.contains("INDEX"));
555        assert!(rich.contains("ON"));
556    }
557
558    #[test]
559    fn test_highlight_keywords() {
560        let ddl = DdlDisplay::new("CREATE TABLE t (id INT PRIMARY KEY NOT NULL);");
561        let rich = ddl.render(80);
562        // All SQL keywords should be present and highlighted
563        assert!(rich.contains("CREATE"));
564        assert!(rich.contains("TABLE"));
565        assert!(rich.contains("PRIMARY"));
566        assert!(rich.contains("KEY"));
567        assert!(rich.contains("NOT"));
568        assert!(rich.contains("NULL"));
569    }
570
571    #[test]
572    fn test_highlight_identifiers() {
573        let ddl = DdlDisplay::new("CREATE TABLE my_table (my_column INT);");
574        let rich = ddl.render(80);
575        // Identifiers should be present
576        assert!(rich.contains("my_table"));
577        assert!(rich.contains("my_column"));
578    }
579
580    #[test]
581    fn test_highlight_types() {
582        let ddl = DdlDisplay::new("CREATE TABLE t (a INT, b TEXT, c BOOLEAN);");
583        let rich = ddl.render(80);
584        // Types should be highlighted as keywords
585        assert!(rich.contains("INT"));
586        assert!(rich.contains("TEXT"));
587        assert!(rich.contains("BOOLEAN"));
588    }
589
590    #[test]
591    fn test_highlight_constraints() {
592        let ddl = DdlDisplay::new(
593            "CREATE TABLE t (id INT PRIMARY KEY, fk INT REFERENCES other(id), u TEXT UNIQUE);",
594        );
595        let rich = ddl.render(80);
596        assert!(rich.contains("PRIMARY"));
597        assert!(rich.contains("KEY"));
598        assert!(rich.contains("REFERENCES"));
599        assert!(rich.contains("UNIQUE"));
600    }
601
602    #[test]
603    fn test_plain_mode_no_color() {
604        let ddl = DdlDisplay::new("CREATE TABLE t (id INT);");
605        let plain = ddl.render_plain();
606        // Plain output should not contain ANSI escape codes
607        assert!(!plain.contains('\x1b'));
608    }
609
610    #[test]
611    fn test_multiline_ddl() {
612        let sql = "CREATE TABLE users (\n    id SERIAL PRIMARY KEY,\n    name TEXT NOT NULL\n);";
613        let ddl = DdlDisplay::new(sql).line_numbers(true);
614        let plain = ddl.render_plain();
615
616        // Should have 4 lines with proper numbering
617        let lines: Vec<&str> = plain.lines().collect();
618        assert_eq!(lines.len(), 4);
619        assert!(lines[0].contains("1 | CREATE TABLE"));
620        assert!(lines[3].contains("4 | );"));
621    }
622
623    #[test]
624    fn test_dialect_as_str() {
625        assert_eq!(SqlDialect::PostgreSQL.as_str(), "PostgreSQL");
626        assert_eq!(SqlDialect::SQLite.as_str(), "SQLite");
627        assert_eq!(SqlDialect::MySQL.as_str(), "MySQL");
628    }
629
630    #[test]
631    fn test_dialect_display() {
632        assert_eq!(format!("{}", SqlDialect::PostgreSQL), "PostgreSQL");
633        assert_eq!(format!("{}", SqlDialect::SQLite), "SQLite");
634        assert_eq!(format!("{}", SqlDialect::MySQL), "MySQL");
635    }
636
637    #[test]
638    fn test_default_dialect() {
639        let ddl = DdlDisplay::new("SELECT 1");
640        assert_eq!(ddl.get_dialect(), SqlDialect::PostgreSQL);
641    }
642
643    #[test]
644    fn test_change_kind_variants() {
645        let added = ChangeRegion::new(1, 1, ChangeKind::Added);
646        let removed = ChangeRegion::new(2, 2, ChangeKind::Removed);
647        let modified = ChangeRegion::new(3, 3, ChangeKind::Modified);
648
649        assert_eq!(added.kind, ChangeKind::Added);
650        assert_eq!(removed.kind, ChangeKind::Removed);
651        assert_eq!(modified.kind, ChangeKind::Modified);
652    }
653
654    #[test]
655    fn test_theme_customization() {
656        let ddl = DdlDisplay::new("SELECT 1;").theme(Theme::light());
657        // Just verify it compiles and renders without panic
658        let _ = ddl.render(80);
659    }
660
661    #[test]
662    fn test_add_change_builder() {
663        let ddl = DdlDisplay::new("Line 1\nLine 2")
664            .add_change(ChangeRegion::new(1, 1, ChangeKind::Added))
665            .add_change(ChangeRegion::new(2, 2, ChangeKind::Removed));
666
667        assert_eq!(ddl.change_regions().len(), 2);
668    }
669
670    #[test]
671    fn test_empty_sql() {
672        let ddl = DdlDisplay::new("");
673        assert_eq!(ddl.sql(), "");
674        assert_eq!(ddl.render_plain(), "");
675    }
676
677    #[test]
678    fn test_default_impl() {
679        let ddl = DdlDisplay::default();
680        assert_eq!(ddl.sql(), "");
681    }
682}