diff --git a/src/component_transforms.rs b/src/component_transforms.rs index ff38cf959011e6da0ad24773695fda3efa95e1a5..ec5a33ffb617506aa27ac6c402f7271cfaa86291 100644 --- a/src/component_transforms.rs +++ b/src/component_transforms.rs @@ -180,7 +180,7 @@ pub fn generate_layout_boilerplate(used: &UsedElements) -> String { if used.code_block { out.push_str(concat!( "\n", - "
<${content}/>
\n", + "
${content}
\n", "\n", "\n", )); @@ -279,14 +279,11 @@ impl NodeValue for CodeBlockComponentNode { if let Some(meta) = &self.meta { fmt.text_raw(&format!(" {}", meta)); } - - fmt.text_raw(">\n"); - // Escape content for Marko template literal: - // - Use {:?} to escape quotes and backslashes - // - Additionally escape ${ to prevent nested template expressions - let escaped = escape_template_literal(&self.content); - fmt.text_raw(&format!("${{\"{}\" }}", escaped)); - fmt.text_raw("\n\n"); + fmt.text_raw(&format!( + " content=\"{}\"", + escape_template_literal(&self.content) + )); + fmt.text_raw("/>\n"); } } diff --git a/src/lib.rs b/src/lib.rs index e5d880d363fba40c1f3ed35e74bfd02f071b2dde..4a4ba1fd3cde03caf1161d26afe75182b319294f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -461,6 +461,25 @@ pub fn err>>(str: T, offset: u32, length: usize) -> Ox OxcDiagnostic::error(str).and_label(LabeledSpan::new(None, offset as usize, length)) } +/// Convert a byte offset into `src` to 1-indexed (line, column). +/// Used by both the WASM layer and error fixture tests. +pub fn offset_to_line_col(src: &str, offset: usize) -> (u32, u32) { + let mut line = 1u32; + let mut col = 1u32; + for (i, ch) in src.char_indices() { + if i >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + (line, col) +} + #[cfg(test)] mod tests { use super::*; @@ -1024,7 +1043,7 @@ mod tests { fn layout_code_block_uses_component_in_body() { let out = run_with_layout("```ts\nconst x = 1;\n```", "./l.marko"); assert!( - out.contains(""), + out.contains(""), + out.contains(" Result { let mut allocator = Allocator::new(); - let expr = parse_stmt_extra(source, 0, &mut allocator)?; - let span = expr.span(); + let (stmt, parse_errors) = parse_stmt_extra(source, 0, &mut allocator)?; + let span = stmt.span(); let len = span.end - span.start; + // If OXC reported parse errors while recovering the statement, surface the + // first one directly — it points at the actual invalid token. + if let Some(first) = parse_errors.into_iter().next() { + return Err(first); + } + if let Some(trailing) = source[span.end as usize..].lines().next() { if !trailing.trim().is_empty() { return Err(err( @@ -28,7 +34,7 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>( source: &'src str, offset: isize, allocator: &'alloc mut Allocator, -) -> Result, OxcDiagnostic> { +) -> Result<(Statement<'alloc>, Vec), OxcDiagnostic> { if source.is_empty() { return Err(err( "Expected expression, found end of file", @@ -43,9 +49,13 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>( .with_jsx(false); let mut result = oxc_parser::Parser::new(allocator, source, source_type).parse(); - if !result.errors.is_empty() && result.program.body.is_empty() { - let first_err = result - .errors + // Capture errors from the initial full-source parse. These are the only errors + // relevant to the caller — errors from truncated-candidate loop iterations below + // are artifacts of parsing incomplete source and must not be surfaced. + let initial_errors = std::mem::take(&mut result.errors); + + if !initial_errors.is_empty() && result.program.body.is_empty() { + let first_err = initial_errors .into_iter() .next() .expect("no errors but no result!"); @@ -84,17 +94,50 @@ fn parse_stmt_extra<'alloc, 'src: 'alloc>( break; } } + + let stmt = result.program.body.into_iter().next().ok_or_else(|| { + err( + "Expected statement", + offset.max(0).cast_unsigned() as u32, + 1, + ) + })?; + // Surface the OXC error only if it falls within the same line as the + // recovered statement (i.e. no newline between statement end and error). + // If there's a newline before the error, it's trailing content (markdown + // or another statement) that the caller will handle. + let stmt_end = stmt.span().end as usize; + let errors = if !source[stmt_end..first_err_offset].contains('\n') { + vec![adjust_err(first_err, offset)] + } else { + vec![] + }; + return Ok((stmt, errors)); } else { assert!(!result.panicked); } - result.program.body.into_iter().next().ok_or_else(|| { + // Full-source parse succeeded; surface only errors that fall within the statement's + // span. Errors beyond the span are about trailing content (markdown, other statements) + // and should be handled by the trailing-content check in the caller, not here. + let stmt = result.program.body.into_iter().next().ok_or_else(|| { err( "Expected statement", offset.max(0).cast_unsigned() as u32, 1, ) - }) + })?; + let stmt_end = stmt.span().end as usize; + let within_stmt_errors: Vec = initial_errors + .into_iter() + .filter(|e| { + e.labels + .as_deref() + .and_then(|l| l.first()) + .map_or(false, |l| l.offset() < stmt_end) + }) + .collect(); + Ok((stmt, within_stmt_errors)) } pub fn parse_expr_extra<'alloc, 'src: 'alloc>( diff --git a/src/wasm.rs b/src/wasm.rs index 124791fa05bfde93d99971ebef51b7005e2fdc2c..16faa76dc7295de71247b3936795d4592e98dcad 100644 --- a/src/wasm.rs +++ b/src/wasm.rs @@ -27,24 +27,6 @@ struct WasmLabel { width: u32, } -/// Convert a byte offset to 1-indexed line and column numbers. -fn offset_to_line_col(src: &str, offset: usize) -> (u32, u32) { - let mut line = 1u32; - let mut col = 1u32; - for (i, ch) in src.char_indices() { - if i >= offset { - break; - } - if ch == '\n' { - line += 1; - col = 1; - } else { - col += 1; - } - } - (line, col) -} - /// Convert an OxcDiagnostic to a WasmDiagnostic. /// - Takes the first label and absorbs its position into the root error. /// - If there are multiple labels or labels with text, forward them. @@ -53,7 +35,7 @@ fn convert_diagnostic(src: &str, diag: &OxcDiagnostic) -> WasmDiagnostic { // Determine root line/column from first label, or default to 1:1 let (line, column) = if let Some(first) = labels.first() { - offset_to_line_col(src, first.offset()) + crate::offset_to_line_col(src, first.offset()) } else { (1, 1) }; @@ -72,7 +54,7 @@ fn convert_diagnostic(src: &str, diag: &OxcDiagnostic) -> WasmDiagnostic { labels .iter() .map(|label| { - let (l, c) = offset_to_line_col(src, label.offset()); + let (l, c) = crate::offset_to_line_col(src, label.offset()); WasmLabel { message: label.label().unwrap_or_default().to_string(), line: l, diff --git a/tests/error-fixtures.rs b/tests/error-fixtures.rs new file mode 100644 index 0000000000000000000000000000000000000000..acfa163f1607479051b303fd521997f5644c4353 --- /dev/null +++ b/tests/error-fixtures.rs @@ -0,0 +1,142 @@ +use markodown::{offset_to_line_col, transform, ComponentImports}; +use std::fs; +use std::path::Path; + +/// Run an error fixture: transform the `.mdo` input, expect it to fail, +/// and compare the formatted errors against the `.err` file. +/// +/// Each line of the `.err` file is `line:col message`, e.g.: +/// 3:5 Mismatched closing tag: expected , found +/// +/// The first label on each diagnostic determines the reported position. +/// Diagnostics with no labels are reported as `1:1`. +fn run_error_fixture(name: &str) { + run_error_fixture_with_options(name, None, None); +} + +fn run_error_fixture_with_options( + name: &str, + layout: Option<&str>, + component_imports: Option, +) { + let base = Path::new("tests/error-fixtures").join(name); + let input_path = base.with_extension("mdo"); + let err_path = base.with_extension("err"); + + let source = fs::read_to_string(&input_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", input_path.display())); + let expected = fs::read_to_string(&err_path) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", err_path.display())); + + let result = transform( + &source, + None, + layout.map(|s| s.to_string()), + component_imports, + None, + false, + None, + ); + + let errors = match result { + Err(e) => e, + Ok(_) => panic!("[{name}] expected transform to fail but it succeeded"), + }; + + // Format each diagnostic as: + // error: + // line:col+len [label message] + // line:col+len [label message] + // Multiple errors are separated by a blank line. + let actual_lines: Vec = errors + .iter() + .map(|diag| { + let mut out = format!("error: {}", diag.message); + if let Some(labels) = diag.labels.as_deref() { + for label in labels { + let (line, col) = offset_to_line_col(&source, label.offset()); + let len = label.len(); + if let Some(msg) = label.label() { + out.push_str(&format!("\n {line}:{col}+{len} {msg}")); + } else { + out.push_str(&format!("\n {line}:{col}+{len}")); + } + } + } + out + }) + .collect(); + + let actual = actual_lines.join("\n\n"); + let expected = expected.trim(); + let actual = actual.trim(); + + assert_eq!( + actual, + expected, + "\n\n[{name}] error output mismatch\n\n--- expected ---\n{expected}\n--- actual ---\n{actual}\n" + ); +} + +// ------------------------------------------------------------------------- +// Mismatched close tag +// ------------------------------------------------------------------------- + +#[test] +fn error_fixture_mismatched_close() { + run_error_fixture("mismatched-close"); +} + +// ------------------------------------------------------------------------- +// Unclosed tag +// ------------------------------------------------------------------------- + +#[test] +fn error_fixture_unclosed_tag() { + run_error_fixture("unclosed-tag"); +} + +// ------------------------------------------------------------------------- +// Close without open +// ------------------------------------------------------------------------- + +#[test] +fn error_fixture_close_without_open() { + run_error_fixture("close-without-open"); +} + +// ------------------------------------------------------------------------- +// Unclosed inline tag +// ------------------------------------------------------------------------- + +#[test] +fn error_fixture_inline_unclosed() { + run_error_fixture("inline-unclosed"); +} + +// ------------------------------------------------------------------------- +// Invalid template expression +// ------------------------------------------------------------------------- + +#[test] +fn error_fixture_bad_template() { + run_error_fixture("bad-template"); +} + +// ------------------------------------------------------------------------- +// Malformed import/statement +// ------------------------------------------------------------------------- + +#[test] +fn error_fixture_bad_statement() { + run_error_fixture("bad-statement"); +} + +// ------------------------------------------------------------------------- +// Invalid YAML frontmatter +// ------------------------------------------------------------------------- + +#[test] +fn error_fixture_bad_frontmatter() { + run_error_fixture("bad-frontmatter"); +} diff --git a/tests/error-fixtures/bad-frontmatter.err b/tests/error-fixtures/bad-frontmatter.err new file mode 100644 index 0000000000000000000000000000000000000000..76171b6bb121e585730a9ec7bd6a53fd1389eee7 --- /dev/null +++ b/tests/error-fixtures/bad-frontmatter.err @@ -0,0 +1,2 @@ +error: YAML syntax error: did not find expected ',' or ']' at line 3 column 1, while parsing a flow sequence at line 2 column 9 + 3:16+1 diff --git a/tests/error-fixtures/bad-frontmatter.mdo b/tests/error-fixtures/bad-frontmatter.mdo new file mode 100644 index 0000000000000000000000000000000000000000..0e631789381c48a2b45e0a751bda65cd8e7c9864 --- /dev/null +++ b/tests/error-fixtures/bad-frontmatter.mdo @@ -0,0 +1,6 @@ +--- +title: valid +broken: [unclosed +--- + +Some content. diff --git a/tests/error-fixtures/bad-statement.err b/tests/error-fixtures/bad-statement.err new file mode 100644 index 0000000000000000000000000000000000000000..42e0108019988811010e03446b8279bde528bc80 --- /dev/null +++ b/tests/error-fixtures/bad-statement.err @@ -0,0 +1,2 @@ +error: Expected `from` but found `@` + 1:10+1 `from` expected diff --git a/tests/error-fixtures/bad-statement.mdo b/tests/error-fixtures/bad-statement.mdo new file mode 100644 index 0000000000000000000000000000000000000000..5d9035ea5defe6944e2fa1a43d7b4dd73fee3a49 --- /dev/null +++ b/tests/error-fixtures/bad-statement.mdo @@ -0,0 +1 @@ +import @@@ from "nowhere"; diff --git a/tests/error-fixtures/bad-template.err b/tests/error-fixtures/bad-template.err new file mode 100644 index 0000000000000000000000000000000000000000..3a00b60ab0923a5703411707e34d62eb96da15a2 --- /dev/null +++ b/tests/error-fixtures/bad-template.err @@ -0,0 +1,2 @@ +error: Unexpected token + 1:22+1 diff --git a/tests/error-fixtures/bad-template.mdo b/tests/error-fixtures/bad-template.mdo new file mode 100644 index 0000000000000000000000000000000000000000..8f2690290c319e51de3d9944aca4c32ceb15b742 --- /dev/null +++ b/tests/error-fixtures/bad-template.mdo @@ -0,0 +1 @@ +Some text ${@@invalid} here. diff --git a/tests/error-fixtures/close-without-open.err b/tests/error-fixtures/close-without-open.err new file mode 100644 index 0000000000000000000000000000000000000000..5558a6c4674b001bd9c5145f54566760debcb86c --- /dev/null +++ b/tests/error-fixtures/close-without-open.err @@ -0,0 +1,2 @@ +error: Closing tag without matching open + 3:3+3 diff --git a/tests/error-fixtures/close-without-open.mdo b/tests/error-fixtures/close-without-open.mdo new file mode 100644 index 0000000000000000000000000000000000000000..ffa0ef206719659a7d1a7caa12f9c2c9f2e7845c --- /dev/null +++ b/tests/error-fixtures/close-without-open.mdo @@ -0,0 +1,3 @@ +hello world + + diff --git a/tests/error-fixtures/inline-unclosed.err b/tests/error-fixtures/inline-unclosed.err new file mode 100644 index 0000000000000000000000000000000000000000..65d1dc184ffa826b8ede84d86a9880050ee2a71b --- /dev/null +++ b/tests/error-fixtures/inline-unclosed.err @@ -0,0 +1,2 @@ +error: Unclosed inline tag + 1:14+1 diff --git a/tests/error-fixtures/inline-unclosed.mdo b/tests/error-fixtures/inline-unclosed.mdo new file mode 100644 index 0000000000000000000000000000000000000000..d94a7da0413e09c877de3db7d100fb17ae8a794c --- /dev/null +++ b/tests/error-fixtures/inline-unclosed.mdo @@ -0,0 +1 @@ +This has an unclosed inline tag. diff --git a/tests/error-fixtures/mismatched-close.err b/tests/error-fixtures/mismatched-close.err new file mode 100644 index 0000000000000000000000000000000000000000..60c33c4602dab9089ce305c235518f42db962bca --- /dev/null +++ b/tests/error-fixtures/mismatched-close.err @@ -0,0 +1,3 @@ +error: Mismatched closing tag: expected , found + 1:1+4 opened here + 3:3+4 closed here diff --git a/tests/error-fixtures/mismatched-close.mdo b/tests/error-fixtures/mismatched-close.mdo new file mode 100644 index 0000000000000000000000000000000000000000..05dcd9fcd268603f9f648bc77671c9bd6536ccd3 --- /dev/null +++ b/tests/error-fixtures/mismatched-close.mdo @@ -0,0 +1,3 @@ +
+hello + diff --git a/tests/error-fixtures/unclosed-tag.err b/tests/error-fixtures/unclosed-tag.err new file mode 100644 index 0000000000000000000000000000000000000000..e72d6138f92fe3edec4d571946fe11d7b92ff0e3 --- /dev/null +++ b/tests/error-fixtures/unclosed-tag.err @@ -0,0 +1,2 @@ +error: Unclosed tag
+ 1:1+4 diff --git a/tests/error-fixtures/unclosed-tag.mdo b/tests/error-fixtures/unclosed-tag.mdo new file mode 100644 index 0000000000000000000000000000000000000000..38d435788c1587040a7f7907d889c8a1ce637f7a --- /dev/null +++ b/tests/error-fixtures/unclosed-tag.mdo @@ -0,0 +1,2 @@ +
+hello world diff --git a/tests/fixtures/25-code-block-import.marko b/tests/fixtures/25-code-block-import.marko index 5896b278594f027ef94c8e532b4595edd31cd595..e5f735501b5de412194844623a53dc8d967facab 100644 --- a/tests/fixtures/25-code-block-import.marko +++ b/tests/fixtures/25-code-block-import.marko @@ -1,9 +1,5 @@ import CodeBlockComponent__markodown__ from "./code-block.marko";

Code Examples

- -${"const greeting = `Hello \${name}!`;\nconst value = \${1 + 2};\n" } - - -${"\n
\${item.name}
\n\n" } - + +

Inline: hello \${world}

diff --git a/tests/fixtures/29-layout-all-components.marko b/tests/fixtures/29-layout-all-components.marko index ee63b84d19c9d8b24180f7ce09a85e5469a0d882..251d7d83ae50923dd785601973b3255c53f634d9 100644 --- a/tests/fixtures/29-layout-all-components.marko +++ b/tests/fixtures/29-layout-all-components.marko @@ -6,7 +6,7 @@ import * as LayoutModule__markodown__ from "./layout.marko"; -
<${content}/>
+
${content}
@@ -26,7 +26,5 @@ Hello

A blockquote.

- -${"const x = 1;\n" } - + diff --git a/wasm.sh b/wasm.sh index b284a14b7c0496419bada7244c3d846ff26dc725..6e2f334e2bd72d0a00155e21df686dfbbc798576 100755 --- a/wasm.sh +++ b/wasm.sh @@ -19,7 +19,7 @@ echo "Generating JS bindings..." "$WASM" , wasm-opt -Os lib/bindgen/markodown_bg.wasm -o lib/bindgen/markodown_bg.wasm -BASE64=$(base64 < "lib/bindgen/markodown_bg.wasm") +BASE64="$(base64 -w 0 < "lib/bindgen/markodown_bg.wasm")" cat > lib/bindgen/wasm_bytes.js << EOF /* @ts-self-types="./wasm_bytes.d.ts" */