Skip to main content

okf_validator/
syntax.rs

1//! Multi-language syntax validation for Attested Computations and embedded scripts.
2//!
3//! Provides fast syntax checking for Python,
4//! JavaScript, TypeScript, Rust, SQL, JSON, YAML, and Bash using pure Rust AST parsers.
5
6use std::fmt;
7
8/// Supported languages for static syntax checking.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Language {
11    /// Python script (`.py`, `python`, `python3`)
12    Python,
13    /// JavaScript script (`.js`, `.mjs`, `.cjs`, `javascript`, `node`)
14    JavaScript,
15    /// TypeScript script (`.ts`, `.mts`, `.cts`, `.tsx`, `.jsx`, `typescript`, `deno`, `bun`)
16    TypeScript,
17    /// Rust source (`.rs`, `rust`)
18    Rust,
19    /// SQL query / migration (`.sql`, `sql`)
20    Sql,
21    /// JSON data structure (`.json`, `json`)
22    Json,
23    /// YAML document (`.yaml`, `.yml`, `yaml`)
24    Yaml,
25    /// Bash / POSIX shell script (`.sh`, `.bash`, `shell`, `sh`)
26    Bash,
27    /// Unknown or unsupported language tag.
28    Unknown,
29}
30
31impl Language {
32    /// Identifies the language from a language tag, runtime identifier, or file extension.
33    #[must_use]
34    pub fn from_tag(tag: &str) -> Self {
35        let tag = tag.trim().to_ascii_lowercase();
36        // Strip common suffixes like `rust,no_run` or `python,ignore`
37        let tag = tag.split([',', ' ']).next().unwrap_or(&tag);
38        match tag {
39            "py" | "python" | "python3" => Self::Python,
40            "js" | "javascript" | "node" | "nodejs" | "mjs" | "cjs" => Self::JavaScript,
41            "ts" | "typescript" | "deno" | "bun" | "mts" | "cts" | "tsx" | "jsx" => {
42                Self::TypeScript
43            }
44            "rs" | "rust" => Self::Rust,
45            "sql" => Self::Sql,
46            "json" => Self::Json,
47            "yaml" | "yml" => Self::Yaml,
48            "sh" | "bash" | "zsh" | "shell" => Self::Bash,
49            _ => Self::Unknown,
50        }
51    }
52
53    /// Display name of the language.
54    #[must_use]
55    pub const fn name(&self) -> &'static str {
56        self.as_str()
57    }
58
59    /// Returns the canonical name of the language as a string slice.
60    #[must_use]
61    pub const fn as_str(&self) -> &'static str {
62        match self {
63            Self::Python => "python",
64            Self::JavaScript => "javascript",
65            Self::TypeScript => "typescript",
66            Self::Rust => "rust",
67            Self::Sql => "sql",
68            Self::Json => "json",
69            Self::Yaml => "yaml",
70            Self::Bash => "bash",
71            Self::Unknown => "unknown",
72        }
73    }
74}
75
76impl fmt::Display for Language {
77    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
78        f.write_str(self.as_str())
79    }
80}
81
82impl AsRef<str> for Language {
83    fn as_ref(&self) -> &str {
84        self.as_str()
85    }
86}
87
88impl std::str::FromStr for Language {
89    type Err = std::convert::Infallible;
90    fn from_str(s: &str) -> Result<Self, Self::Err> {
91        Ok(Self::from_tag(s))
92    }
93}
94
95/// A parsed fenced code block from markdown.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct FencedCodeBlock {
98    /// The language tag if specified (e.g. `python`, `rust`).
99    pub language: Option<String>,
100    /// The inner code content.
101    pub code: String,
102    /// 1-based start line of the opening fence in the markdown document.
103    pub start_line: usize,
104}
105
106/// Extracts all fenced code blocks (delimited by 3+ backticks or 3+ tildes) from markdown text.
107#[must_use]
108pub fn extract_fenced_code_blocks(markdown: &str) -> Vec<FencedCodeBlock> {
109    let mut blocks = Vec::new();
110    let mut in_fence = false;
111    let mut fence_char = '`';
112    let mut fence_len = 0;
113    let mut lang: Option<String> = None;
114    let mut block_lines: Vec<&str> = Vec::new();
115    let mut start_line = 0;
116
117    for (line_idx, line) in markdown.lines().enumerate() {
118        let line_no = line_idx + 1;
119        let trimmed = line.trim_start();
120        let indent = line.len() - trimmed.len();
121
122        if in_fence {
123            let close_indent = line.len() - trimmed.len();
124            let is_close = close_indent <= 3 && {
125                let count = trimmed.chars().take_while(|&c| c == fence_char).count();
126                count >= fence_len && trimmed[count..].trim().is_empty()
127            };
128
129            if is_close {
130                in_fence = false;
131                let code = block_lines.join("\n");
132                blocks.push(FencedCodeBlock {
133                    language: lang.take(),
134                    code,
135                    start_line,
136                });
137            } else {
138                block_lines.push(line);
139            }
140        } else if indent <= 3 && (trimmed.starts_with("```") || trimmed.starts_with("~~~")) {
141            let ch = trimmed.chars().next().unwrap_or('`');
142            let count = trimmed.chars().take_while(|&c| c == ch).count();
143            if count >= 3 {
144                in_fence = true;
145                fence_char = ch;
146                fence_len = count;
147                start_line = line_no;
148                let tag = trimmed[count..].trim();
149                let first_tag = tag.split([',', ' ', '\t']).next().unwrap_or("");
150                lang = if first_tag.is_empty() {
151                    None
152                } else {
153                    Some(first_tag.to_string())
154                };
155                block_lines.clear();
156            }
157        }
158    }
159
160    blocks
161}
162
163/// A syntax diagnostic produced by static AST parsing.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct SyntaxError {
166    /// The language that was being parsed.
167    pub language: String,
168    /// Human-readable error description.
169    pub message: String,
170    /// 1-based line number if available.
171    pub line: Option<usize>,
172    /// 1-based column number if available.
173    pub column: Option<usize>,
174}
175
176impl fmt::Display for SyntaxError {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        if let Some(line) = self.line {
179            if let Some(col) = self.column {
180                write!(f, "{line}:{col}: {}", self.message)
181            } else {
182                write!(f, "{line}: {}", self.message)
183            }
184        } else {
185            write!(f, "{}", self.message)
186        }
187    }
188}
189
190impl std::error::Error for SyntaxError {}
191
192fn offset_to_line_col(source: &str, offset: usize) -> (Option<usize>, Option<usize>) {
193    let bounded = offset.min(source.len());
194    let safe_offset = source.floor_char_boundary(bounded);
195    let line = source[..safe_offset].matches('\n').count() + 1;
196    let last_newline = source[..safe_offset].rfind('\n').map_or(0, |idx| idx + 1);
197    let column = source[last_newline..safe_offset].chars().count() + 1;
198    (Some(line), Some(column))
199}
200
201/// Checks the syntax of `source` for the given `language` tag or name.
202///
203/// Returns `Ok(())` if the syntax is valid or if the language is unknown / unsupported,
204/// or `Err(SyntaxError)` if a syntax error is discovered.
205///
206/// # Errors
207///
208/// Returns [`SyntaxError`] if the source code contains syntax or parse errors.
209pub fn check_syntax(language_tag: &str, source: &str) -> Result<(), SyntaxError> {
210    let lang = Language::from_tag(language_tag);
211    match lang {
212        Language::Python => check_python(source),
213        Language::JavaScript => check_javascript(source, false),
214        Language::TypeScript => check_javascript(source, true),
215        Language::Rust => check_rust(source),
216        Language::Sql => check_sql(source),
217        Language::Json => check_json(source),
218        Language::Yaml => check_yaml(source),
219        Language::Bash => check_bash(source),
220        Language::Unknown => Ok(()),
221    }
222}
223
224fn check_python(source: &str) -> Result<(), SyntaxError> {
225    match rustpython_parser::parse(source, rustpython_parser::Mode::Module, "<computation>") {
226        Ok(_) => Ok(()),
227        Err(err) => {
228            let (line, column) = offset_to_line_col(source, err.offset.to_usize());
229            Err(SyntaxError {
230                language: "python".to_string(),
231                message: err.error.to_string(),
232                line,
233                column,
234            })
235        }
236    }
237}
238
239fn check_javascript(source: &str, typescript: bool) -> Result<(), SyntaxError> {
240    let allocator = oxc_allocator::Allocator::default();
241    let mut source_type = oxc_span::SourceType::default().with_module(true);
242    if typescript {
243        source_type = source_type.with_typescript(true).with_jsx(true);
244    } else {
245        source_type = source_type.with_jsx(true);
246    }
247
248    let parser = oxc_parser::Parser::new(&allocator, source, source_type);
249    let ret = parser.parse();
250
251    if let Some(first_diag) = ret.diagnostics.first() {
252        let (line, column) = first_diag.labels.first().map_or((None, None), |l| {
253            let offset = usize::try_from(l.offset()).unwrap_or(0);
254            offset_to_line_col(source, offset)
255        });
256
257        let msg = first_diag.to_string();
258        let lang = if typescript {
259            "typescript"
260        } else {
261            "javascript"
262        };
263        return Err(SyntaxError {
264            language: lang.to_string(),
265            message: msg,
266            line,
267            column,
268        });
269    }
270
271    Ok(())
272}
273
274fn check_rust(source: &str) -> Result<(), SyntaxError> {
275    if syn::parse_file(source).is_ok() {
276        return Ok(());
277    }
278    // Try wrapping in a function body to allow statements / snippet validation
279    let wrapped = format!("fn __okf_snippet_check__() {{\n{source}\n}}");
280    if syn::parse_file(&wrapped).is_ok() {
281        return Ok(());
282    }
283    if syn::parse_str::<syn::Item>(source).is_ok() {
284        return Ok(());
285    }
286
287    match syn::parse_file(source) {
288        Ok(_) => Ok(()),
289        Err(err) => Err(SyntaxError {
290            language: "rust".to_string(),
291            message: err.to_string(),
292            line: None,
293            column: None,
294        }),
295    }
296}
297
298fn check_sql(source: &str) -> Result<(), SyntaxError> {
299    let dialect = sqlparser::dialect::GenericDialect {};
300    match sqlparser::parser::Parser::parse_sql(&dialect, source) {
301        Ok(_) => Ok(()),
302        Err(err) => Err(SyntaxError {
303            language: "sql".to_string(),
304            message: err.to_string(),
305            line: None,
306            column: None,
307        }),
308    }
309}
310
311fn check_json(source: &str) -> Result<(), SyntaxError> {
312    match serde_json::from_str::<serde_json::Value>(source) {
313        Ok(_) => Ok(()),
314        Err(err) => Err(SyntaxError {
315            language: "json".to_string(),
316            message: err.to_string(),
317            line: Some(err.line()),
318            column: Some(err.column()),
319        }),
320    }
321}
322
323fn check_yaml(source: &str) -> Result<(), SyntaxError> {
324    match okf_core::yaml::Value::parse(source) {
325        Ok(_) => Ok(()),
326        Err(err) => Err(SyntaxError {
327            language: "yaml".to_string(),
328            message: err.to_string(),
329            line: None,
330            column: None,
331        }),
332    }
333}
334
335fn check_bash(source: &str) -> Result<(), SyntaxError> {
336    let mut quote: Option<char> = None;
337    let mut escaped = false;
338    let mut paren_depth: usize = 0;
339    let mut brace_depth: usize = 0;
340
341    for (line_idx, line) in source.lines().enumerate() {
342        let line_no = line_idx + 1;
343        let mut prev_ch: Option<char> = None;
344
345        for (col_idx, ch) in line.chars().enumerate() {
346            let col_no = col_idx + 1;
347
348            if quote.is_none()
349                && (prev_ch.is_none()
350                    || prev_ch == Some(' ')
351                    || prev_ch == Some('\t')
352                    || prev_ch == Some(';')
353                    || prev_ch == Some('&')
354                    || prev_ch == Some('|'))
355                && ch == '#'
356            {
357                break;
358            }
359
360            if quote == Some('\'') {
361                if ch == '\'' {
362                    quote = None;
363                }
364                prev_ch = Some(ch);
365                continue;
366            }
367
368            if escaped {
369                escaped = false;
370                prev_ch = Some(ch);
371                continue;
372            }
373
374            if ch == '\\' {
375                escaped = true;
376                prev_ch = Some(ch);
377                continue;
378            }
379
380            if let Some(q) = quote {
381                if ch == q {
382                    quote = None;
383                }
384            } else {
385                match ch {
386                    '\'' | '"' | '`' => quote = Some(ch),
387                    '(' => paren_depth += 1,
388                    ')' => {
389                        if paren_depth == 0 {
390                            return Err(SyntaxError {
391                                language: "bash".to_string(),
392                                message: "unexpected closing parenthesis ')'".to_string(),
393                                line: Some(line_no),
394                                column: Some(col_no),
395                            });
396                        }
397                        paren_depth -= 1;
398                    }
399                    '{' => brace_depth += 1,
400                    '}' => {
401                        if brace_depth == 0 {
402                            return Err(SyntaxError {
403                                language: "bash".to_string(),
404                                message: "unexpected closing brace '}'".to_string(),
405                                line: Some(line_no),
406                                column: Some(col_no),
407                            });
408                        }
409                        brace_depth -= 1;
410                    }
411                    _ => {}
412                }
413            }
414            prev_ch = Some(ch);
415        }
416    }
417
418    if let Some(q) = quote {
419        return Err(SyntaxError {
420            language: "bash".to_string(),
421            message: format!("unclosed quote `{q}`"),
422            line: None,
423            column: None,
424        });
425    }
426    if paren_depth > 0 {
427        return Err(SyntaxError {
428            language: "bash".to_string(),
429            message: "unclosed parenthesis '('".to_string(),
430            line: None,
431            column: None,
432        });
433    }
434    if brace_depth > 0 {
435        return Err(SyntaxError {
436            language: "bash".to_string(),
437            message: "unclosed brace '{'".to_string(),
438            line: None,
439            column: None,
440        });
441    }
442
443    Ok(())
444}