1use std::fmt;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Language {
11 Python,
13 JavaScript,
15 TypeScript,
17 Rust,
19 Sql,
21 Json,
23 Yaml,
25 Bash,
27 Unknown,
29}
30
31impl Language {
32 #[must_use]
34 pub fn from_tag(tag: &str) -> Self {
35 let tag = tag.trim().to_ascii_lowercase();
36 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 #[must_use]
55 pub const fn name(&self) -> &'static str {
56 self.as_str()
57 }
58
59 #[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#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct FencedCodeBlock {
98 pub language: Option<String>,
100 pub code: String,
102 pub start_line: usize,
104}
105
106#[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#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct SyntaxError {
166 pub language: String,
168 pub message: String,
170 pub line: Option<usize>,
172 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
201pub 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 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}