Skip to main content

nounsql_core/
highlight.rs

1//! NounSQL のシンタックスハイライト。ドキュメントサイトと WASM 版で共有する。
2
3use crate::lexer::{Tok, lex_all};
4
5const BLOCK_KEYWORDS: &[&str] = &[
6    "table",
7    "mixin",
8    "blueprint",
9    "naming",
10    "constraints",
11    "nouns",
12];
13
14const STATEMENT_KEYWORDS: &[&str] = &[
15    "column",
16    "pk",
17    "index",
18    "use",
19    "override",
20    "except",
21    "belongs_to",
22    "unique_belongs_to",
23    "has_many",
24    "has_one",
25    "unique",
26    "comment",
27    "name",
28];
29
30/// 宣言文のうち、直後の識別子が「型・テーブル」を指すもの。
31const NAMES_A_TYPE: &[&str] = &[
32    "table",
33    "mixin",
34    "blueprint",
35    "use",
36    "belongs_to",
37    "unique_belongs_to",
38    "has_many",
39    "has_one",
40];
41
42const MACROS: &[&str] = &["associate", "apply_blueprint"];
43const FUNCTIONS: &[&str] = &["eval", "noun", "singular", "plural", "short", "desc"];
44const CONSTANTS: &[&str] = &[
45    "true",
46    "false",
47    "cascade",
48    "restrict",
49    "set_null",
50    "no_action",
51    "plural",
52    "singular",
53];
54
55/// NounSQL のソースを、実際の lexer を使って HTML に色付けする。
56///
57/// CSS クラスだけを付けるので、配色は利用側の stylesheet が決める。
58pub fn to_html(src: &str) -> String {
59    let (tokens, _) = lex_all(src);
60    let mut out = String::new();
61    let mut cursor = 0usize;
62    let mut prev_ident: Option<String> = None;
63    // 行頭のキーワード。1行1文なので、これで文の種類が判る。
64    let mut line_head: Option<String> = None;
65
66    for (i, tok) in tokens.iter().enumerate() {
67        if tok.value == Tok::Eof {
68            break;
69        }
70        // トークン間の空白をそのまま出す。
71        if tok.span.start > cursor {
72            out.push_str(&escape(&src[cursor..tok.span.start]));
73        }
74        let text = &src[tok.span.start..tok.span.end];
75        let next_is_eq = matches!(tokens.get(i + 1).map(|t| &t.value), Some(Tok::Eq));
76        let next_is_paren = matches!(tokens.get(i + 1).map(|t| &t.value), Some(Tok::LParen));
77
78        let class = match &tok.value {
79            Tok::Comment(_) => Some("c"),
80            Tok::Str(_) => Some("s"),
81            Tok::Num(_) => Some("n"),
82            Tok::Eval(_) => Some("fn"),
83            Tok::Ident(name) => classify(name, &prev_ident, &line_head, next_is_eq, next_is_paren),
84            _ => None,
85        };
86
87        match &tok.value {
88            Tok::Eval(body) => {
89                out.push_str("<span class=\"fn\">eval</span>(");
90                out.push_str(&format!("<span class=\"raw\">{}</span>", escape(body)));
91                out.push(')');
92            }
93            Tok::Str(_) => out.push_str(&string_html(text)),
94            _ => match class {
95                Some(c) => out.push_str(&format!("<span class=\"{c}\">{}</span>", escape(text))),
96                None => out.push_str(&escape(text)),
97            },
98        }
99
100        match &tok.value {
101            Tok::Ident(name) => {
102                if line_head.is_none() {
103                    line_head = Some(name.clone());
104                }
105                prev_ident = Some(name.clone());
106            }
107            Tok::Newline | Tok::LBrace | Tok::RBrace => {
108                prev_ident = None;
109                line_head = None;
110            }
111            // `=` を挟んでも直前の識別子を覚えておく(`type=text` の型名を拾うため)。
112            Tok::Eq | Tok::Comment(_) => {}
113            _ => prev_ident = None,
114        }
115        cursor = tok.span.end;
116    }
117    if cursor < src.len() {
118        out.push_str(&escape(&src[cursor..]));
119    }
120    out
121}
122
123fn classify(
124    name: &str,
125    prev: &Option<String>,
126    line_head: &Option<String>,
127    next_is_eq: bool,
128    next_is_paren: bool,
129) -> Option<&'static str> {
130    if next_is_paren {
131        if MACROS.contains(&name) {
132            return Some("macro");
133        }
134        if FUNCTIONS.contains(&name) {
135            return Some("fn");
136        }
137    }
138    if next_is_eq {
139        return Some("attr");
140    }
141    // 直前の語で決まるもの。
142    if let Some(p) = prev.as_deref() {
143        if NAMES_A_TYPE.contains(&p) {
144            return Some("ty");
145        }
146        match p {
147            "column" | "override" => return Some("member"),
148            "type" => return Some("t"),
149            _ => {}
150        }
151    }
152    if BLOCK_KEYWORDS.contains(&name) || STATEMENT_KEYWORDS.contains(&name) {
153        return Some("k");
154    }
155    if CONSTANTS.contains(&name) {
156        return Some("cn");
157    }
158    // 文の種類で決まるもの。列の並びや引数の並び。
159    match line_head.as_deref() {
160        Some("pk" | "index" | "except") => Some("member"),
161        Some(head) if MACROS.contains(&head) => Some("ty"),
162        Some("blueprint") => Some("var"),
163        _ => None,
164    }
165}
166
167/// 文字列リテラル内の `${...}` を別色にする。
168fn string_html(text: &str) -> String {
169    let mut out = String::from("<span class=\"s\">");
170    let mut rest = text;
171    while let Some(i) = rest.find("${") {
172        let Some(close) = rest[i..].find('}') else {
173            break;
174        };
175        out.push_str(&escape(&rest[..i]));
176        out.push_str(&format!(
177            "<span class=\"tpl\">{}</span>",
178            escape(&rest[i..i + close + 1])
179        ));
180        rest = &rest[i + close + 1..];
181    }
182    out.push_str(&escape(rest));
183    out.push_str("</span>");
184    out
185}
186
187pub fn escape(s: &str) -> String {
188    s.replace('&', "&amp;")
189        .replace('<', "&lt;")
190        .replace('>', "&gt;")
191}
192
193const SQL_KEYWORDS: &[&str] = &[
194    "CREATE",
195    "TABLE",
196    "ALTER",
197    "ADD",
198    "CONSTRAINT",
199    "PRIMARY",
200    "KEY",
201    "FOREIGN",
202    "REFERENCES",
203    "ON",
204    "DELETE",
205    "UPDATE",
206    "CASCADE",
207    "RESTRICT",
208    "SET",
209    "NULL",
210    "NOT",
211    "DEFAULT",
212    "UNIQUE",
213    "INDEX",
214    "COMMENT",
215    "IS",
216    "OR",
217    "REPLACE",
218    "FUNCTION",
219    "RETURNS",
220    "TRIGGER",
221    "BEFORE",
222    "FOR",
223    "EACH",
224    "ROW",
225    "EXECUTE",
226    "LANGUAGE",
227    "BEGIN",
228    "END",
229    "RETURN",
230    "AS",
231];
232
233/// 生成した DDL を色付けする。
234pub fn sql_to_html(src: &str) -> String {
235    let mut out = String::new();
236    for token in split_keep(src) {
237        if SQL_KEYWORDS.contains(&token.to_ascii_uppercase().as_str()) {
238            out.push_str(&format!("<span class=\"k\">{}</span>", escape(token)));
239        } else if token.starts_with('\'') {
240            out.push_str(&format!("<span class=\"s\">{}</span>", escape(token)));
241        } else {
242            out.push_str(&escape(token));
243        }
244    }
245    out
246}
247
248/// 単語と区切りを保ったまま分割する。
249fn split_keep(src: &str) -> Vec<&str> {
250    let mut parts = Vec::new();
251    let mut start = 0;
252    let mut in_word = false;
253    for (i, ch) in src.char_indices() {
254        let word = ch.is_alphanumeric() || ch == '_';
255        if word != in_word {
256            if i > start {
257                parts.push(&src[start..i]);
258            }
259            start = i;
260            in_word = word;
261        }
262    }
263    if start < src.len() {
264        parts.push(&src[start..]);
265    }
266    parts
267}