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