sqlmodel_console/renderables/
ddl_display.rs1use crate::renderables::sql_syntax::{SqlHighlighter, SqlSegment, SqlToken};
30use crate::theme::Theme;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum SqlDialect {
38 #[default]
40 PostgreSQL,
41 SQLite,
43 MySQL,
45}
46
47impl SqlDialect {
48 #[must_use]
50 pub fn ddl_keywords(&self) -> &'static [&'static str] {
51 match self {
52 Self::PostgreSQL => &[
53 "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 "AUTOINCREMENT",
73 "WITHOUT",
74 "ROWID",
75 "STRICT",
76 "VIRTUAL",
77 "USING",
78 "FTS5",
79 "RTREE",
80 ],
81 Self::MySQL => &[
82 "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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum ChangeKind {
120 Added,
122 Removed,
124 Modified,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ChangeRegion {
131 pub start_line: usize,
133 pub end_line: usize,
135 pub kind: ChangeKind,
137}
138
139impl ChangeRegion {
140 #[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 #[must_use]
152 pub fn contains_line(&self, line: usize) -> bool {
153 line >= self.start_line && line <= self.end_line
154 }
155}
156
157#[derive(Debug, Clone)]
162pub struct DdlDisplay {
163 sql: String,
165 dialect: SqlDialect,
167 theme: Theme,
169 line_numbers: bool,
171 change_regions: Vec<ChangeRegion>,
173}
174
175impl DdlDisplay {
176 #[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 #[must_use]
198 pub fn dialect(mut self, dialect: SqlDialect) -> Self {
199 self.dialect = dialect;
200 self
201 }
202
203 #[must_use]
205 pub fn line_numbers(mut self, show: bool) -> Self {
206 self.line_numbers = show;
207 self
208 }
209
210 #[must_use]
212 pub fn theme(mut self, theme: Theme) -> Self {
213 self.theme = theme;
214 self
215 }
216
217 #[must_use]
219 pub fn highlight_changes(mut self, regions: Vec<ChangeRegion>) -> Self {
220 self.change_regions = regions;
221 self
222 }
223
224 #[must_use]
226 pub fn add_change(mut self, region: ChangeRegion) -> Self {
227 self.change_regions.push(region);
228 self
229 }
230
231 #[must_use]
233 pub fn sql(&self) -> &str {
234 &self.sql
235 }
236
237 #[must_use]
239 pub fn get_dialect(&self) -> SqlDialect {
240 self.dialect
241 }
242
243 #[must_use]
245 pub fn shows_line_numbers(&self) -> bool {
246 self.line_numbers
247 }
248
249 #[must_use]
251 pub fn change_regions(&self) -> &[ChangeRegion] {
252 &self.change_regions
253 }
254
255 #[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 #[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 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 let change_bg = self.get_change_background(line_num);
316
317 if let Some(bg) = &change_bg {
319 line_output.push_str(bg);
320 }
321
322 let styled_line = self.highlight_line(line, &highlighter);
324 line_output.push_str(&styled_line);
325
326 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 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 fn colorize_segment(&self, seg: &SqlSegment) -> String {
351 let reset = "\x1b[0m";
352
353 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 let color = self.theme.sql_keyword.color_code();
360 return format!("{color}{}{reset}", seg.text);
361 }
362 }
363
364 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 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(), ChangeKind::Removed => "\x1b[48;2;80;0;0m".to_string(), ChangeKind::Modified => "\x1b[48;2;80;80;0m".to_string(), });
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 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 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 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 assert!(rich.contains('\x1b'));
480 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 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 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 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 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 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 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 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}