1use std::fmt;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Language {
30 Python,
32 JavaScript,
34 TypeScript,
36 Rust,
38 Sql,
40 Json,
42 Yaml,
44 Bash,
46 Unknown,
48}
49
50impl Language {
51 #[must_use]
53 pub fn from_tag(tag: &str) -> Self {
54 let tag = tag.trim().to_ascii_lowercase();
55 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 #[must_use]
74 pub const fn name(&self) -> &'static str {
75 self.as_str()
76 }
77
78 #[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 #[must_use]
104 pub const fn is_supported(self) -> bool {
105 self.checker().is_some()
106 }
107
108 #[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
133type 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#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct FencedCodeBlock {
158 pub language: Option<String>,
160 pub code: String,
162 pub start_line: usize,
164}
165
166#[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#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct SyntaxError {
226 pub language: String,
228 pub message: String,
230 pub line: Option<usize>,
232 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
262pub 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 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}