Skip to main content

bloq_graph/parser/
mod.rs

1//! Parsing and lowering for BLOG graphs and module programs.
2
3pub mod ast;
4mod lower;
5mod parse;
6mod program;
7
8use crate::action::Action;
9use crate::{BlockGraph, BlockGraphError, Direction};
10use glam::IVec3;
11use thiserror::Error;
12
13pub(crate) use parse::{
14    cube_height_from_str, is_valid_identifier, is_valid_resource_type, is_valid_simple_identifier,
15};
16
17use crate::parser::ast::Span;
18use std::path::Path;
19use std::sync::Arc;
20
21/// An error from parsing or lowering `.blog` source text.
22///
23/// Most variants carry a [`Span`] into the source for diagnostic rendering; see
24/// [`ParseError::render_diagnostic`].
25#[derive(Debug, Clone, Error)]
26#[non_exhaustive]
27pub enum ParseError {
28    /// Source text does not satisfy BLOG grammar.
29    #[error("syntax error: {message}")]
30    Syntax {
31        /// Parser diagnostic.
32        message: String,
33        /// Source range containing the syntax error.
34        span: Span,
35    },
36
37    /// A statement references an undeclared block ID.
38    #[error("undefined block ID {id}")]
39    UndefinedId {
40        /// Missing block ID.
41        id: u32,
42        /// Source range containing the reference.
43        span: Span,
44    },
45
46    /// A block ID is declared more than once.
47    #[error("duplicate block ID {id}")]
48    DuplicateId {
49        /// Repeated block ID.
50        id: u32,
51        /// Source range containing the duplicate declaration.
52        span: Span,
53    },
54
55    /// A branch region name is declared more than once.
56    #[error("duplicate branch region '{name}'")]
57    DuplicateBranchName {
58        /// Repeated branch name.
59        name: String,
60        /// Source range containing the duplicate declaration.
61        span: Span,
62    },
63
64    /// An action references an undeclared branch region.
65    #[error("undefined branch region '{name}'")]
66    UndefinedBranchName {
67        /// Missing branch name.
68        name: String,
69        /// Source range containing the reference.
70        span: Span,
71    },
72
73    /// Two pipe endpoints do not define a supported direction.
74    #[error("invalid direction between {from} and {to}: {message}")]
75    InvalidDirection {
76        /// First endpoint.
77        from: IVec3,
78        /// Second endpoint.
79        to: IVec3,
80        /// Source range containing the pipe.
81        span: Span,
82        /// Direction validation diagnostic.
83        message: String,
84    },
85
86    /// A walking block has invalid endpoints or boundary data.
87    #[error("invalid walking block from {start} to {end}: {message}")]
88    InvalidWalkingBlock {
89        /// Starting lattice position.
90        start: IVec3,
91        /// Ending lattice position.
92        end: IVec3,
93        /// Source range containing the block.
94        span: Span,
95        /// Walking-block validation diagnostic.
96        message: String,
97    },
98
99    /// A patch-rotation block has invalid endpoints or basis data.
100    #[error("invalid patch rotation block from {start} to {end}: {message}")]
101    InvalidPatchRotationBlock {
102        /// Starting lattice position.
103        start: IVec3,
104        /// Ending lattice position.
105        end: IVec3,
106        /// Source range containing the block.
107        span: Span,
108        /// Patch-rotation validation diagnostic.
109        message: String,
110    },
111
112    /// Coordinate arithmetic overflowed during lowering.
113    #[error("coordinate arithmetic overflow: {message}")]
114    CoordinateOverflow {
115        /// Source range whose lowering overflowed.
116        span: Span,
117        /// Overflow diagnostic.
118        message: String,
119    },
120
121    /// Spatially connected cubes specify different symbolic heights.
122    #[error(
123        "conflicting cube heights in one spatial component: `height={height}` at {pos} and \
124         `height={other_height}` at {other_pos}; spatially merged cubes share their syndrome rounds, \
125         so one component has one height"
126    )]
127    ConflictingCubeHeights {
128        /// Position of the first conflicting cube.
129        pos: IVec3,
130        /// Height of the first conflicting cube.
131        height: crate::CubeHeight,
132        /// Position of the second conflicting cube.
133        other_pos: IVec3,
134        /// Height of the second conflicting cube.
135        other_height: crate::CubeHeight,
136        /// Source range containing the conflict.
137        span: Span,
138    },
139
140    /// The source declares a BLOG version this parser does not support.
141    #[error("unsupported BLOG version {major}.{minor}")]
142    UnsupportedVersion {
143        /// Declared major version.
144        major: u32,
145        /// Declared minor version.
146        minor: u32,
147        /// Source range containing the version declaration.
148        span: Span,
149    },
150
151    /// A graph-construction or action-list failure raised while lowering the
152    /// parsed AST into a [`BlockGraph`].
153    ///
154    /// When present, `span` points at the source statement that introduced the
155    /// invalid graph element — the block or pipe statement whose lowering the
156    /// graph rejected. It is `None` when no single statement is responsible
157    /// (e.g. action-list validation).
158    #[error("{source}")]
159    Graph {
160        /// Underlying graph-construction failure.
161        source: Box<BlockGraphError>,
162        /// Source statement responsible for the failure, when known.
163        span: Option<Span>,
164    },
165}
166
167impl ParseError {
168    /// Source span for diagnostic rendering (if available).
169    pub fn span(&self) -> Option<Span> {
170        match self {
171            ParseError::Syntax { span, .. }
172            | ParseError::UndefinedId { span, .. }
173            | ParseError::DuplicateId { span, .. }
174            | ParseError::DuplicateBranchName { span, .. }
175            | ParseError::UndefinedBranchName { span, .. }
176            | ParseError::InvalidDirection { span, .. }
177            | ParseError::InvalidWalkingBlock { span, .. }
178            | ParseError::InvalidPatchRotationBlock { span, .. }
179            | ParseError::CoordinateOverflow { span, .. }
180            | ParseError::ConflictingCubeHeights { span, .. }
181            | ParseError::UnsupportedVersion { span, .. } => Some(*span),
182            ParseError::Graph { span, .. } => *span,
183        }
184    }
185
186    /// Render a rustc-style diagnostic using ariadne, with ANSI colors for
187    /// terminal display.
188    pub fn render_diagnostic(&self, filename: &str, source: &str) -> String {
189        self.render(filename, source, true)
190    }
191
192    /// Render the same diagnostic as [`render_diagnostic`] but without ANSI
193    /// escape sequences, for non-terminal consumers (exception messages,
194    /// logs, files).
195    ///
196    /// [`render_diagnostic`]: Self::render_diagnostic
197    pub fn render_diagnostic_plain(&self, filename: &str, source: &str) -> String {
198        self.render(filename, source, false)
199    }
200
201    fn render(&self, filename: &str, source: &str, color: bool) -> String {
202        use ariadne::{Color, Config, IndexType, Label, Report, ReportKind, Source};
203
204        let message = self.to_string();
205        let span = self.span();
206        let config = Config::default()
207            .with_color(color)
208            .with_index_type(IndexType::Byte);
209
210        let mut buf = Vec::new();
211
212        let report = match span {
213            Some(s) => {
214                let range = s.start as usize..s.end as usize;
215                Report::build(ReportKind::Error, (filename, range.clone()))
216                    .with_config(config)
217                    .with_message(&message)
218                    .with_label(
219                        // The report already carries the full message; the label
220                        // only marks the offending location in the source.
221                        Label::new((filename, range))
222                            .with_message("here")
223                            .with_color(Color::Red),
224                    )
225                    .finish()
226            }
227            None => Report::build(ReportKind::Error, (filename, 0..0))
228                .with_config(config)
229                .with_message(&message)
230                .finish(),
231        };
232
233        report
234            .write_for_stdout((filename, Source::from(source)), &mut buf)
235            .expect("write to Vec<u8> cannot fail");
236
237        String::from_utf8(buf).expect("ariadne output is valid UTF-8")
238    }
239}
240
241/// Parse BLOG source text and lower it into a [`BlockGraph`].
242///
243/// BLOG is an authoring format, so the returned graph may be structurally
244/// incomplete. Call [`BlockGraph::validate`] when a complete graph is required.
245///
246/// # Errors
247///
248/// Returns a [`ParseError`] if the input is not valid BLOG syntax, references
249/// undefined or duplicate block IDs, uses an unsupported version, or cannot be
250/// represented as a graph.
251///
252/// # Examples
253///
254/// ```
255/// let graph = bloq_graph::parse_blog_to_graph(
256///     "BLOG 1.0\n\n  0: ZXZ [0,0,0]\n",
257/// )?;
258/// assert_eq!(graph.block_count(), 1);
259/// # Ok::<(), bloq_graph::BlockGraphError>(())
260/// ```
261pub fn parse_blog_to_graph(input: &str) -> Result<BlockGraph, BlockGraphError> {
262    parse_blog_to_graph_with_limits(input, crate::ModuleCertificationLimits::DEFAULT)
263}
264
265/// Parses BLOG into a hierarchy-preserving graph with explicit source budgets.
266///
267/// # Errors
268///
269/// Returns typed syntax, graph, module, or resource-limit errors.
270pub fn parse_blog_to_graph_with_limits(
271    input: &str,
272    limits: crate::ModuleCertificationLimits,
273) -> Result<BlockGraph, BlockGraphError> {
274    parse_graph_input(input, limits)
275}
276
277/// Parse BLOG graph-body text with explicit source-analysis limits.
278///
279/// # Errors
280///
281/// Returns a syntax, graph-construction, action-analysis, or resource-limit error.
282#[cfg(test)]
283pub(super) fn parse_blog_body(input: &str) -> Result<BlockGraph, ParseError> {
284    parse_blog_body_with_limits(input, crate::ModuleCertificationLimits::DEFAULT)
285}
286
287fn parse_blog_body_with_limits(
288    input: &str,
289    limits: crate::ModuleCertificationLimits,
290) -> Result<BlockGraph, ParseError> {
291    let ast = parse::parse(input)?;
292    lower::lower_with_inputs_and_limits(&ast, std::iter::empty(), limits)
293}
294
295// The explicit graph constructor accepts executable BLOG and body serialization.
296// Only syntax selects a grammar; a module or resource failure never falls back
297// to a different lowering path.
298pub(crate) fn parse_graph_input(
299    input: &str,
300    limits: crate::ModuleCertificationLimits,
301) -> Result<BlockGraph, BlockGraphError> {
302    match parse::parse_program(input) {
303        Ok(source) if source.modules.is_empty() && source.imports.is_empty() => {
304            parse_blog_body_with_limits(input, limits).map_err(Into::into)
305        }
306        Ok(source) => program::lower(&source, limits).map_err(|error| match error {
307            crate::ModuleError::Parse(error) => BlockGraphError::Parse(error),
308            error => BlockGraphError::ModuleSource(Arc::new(error)),
309        }),
310        Err(program_error) => parse_blog_body_with_limits(input, limits).map_err(|error| {
311            if matches!(error, ParseError::Syntax { .. })
312                && program_error.span().map(|span| span.start)
313                    >= error.span().map(|span| span.start)
314            {
315                program_error.into()
316            } else {
317                error.into()
318            }
319        }),
320    }
321}
322
323pub(crate) fn load_graph_input(
324    path: &Path,
325    limits: crate::ModuleCertificationLimits,
326) -> Result<BlockGraph, BlockGraphError> {
327    let text = std::fs::read_to_string(path).map_err(|source| BlockGraphError::Io {
328        path: path.to_path_buf(),
329        source: Arc::new(source),
330    })?;
331    if !parse::parse_program(&text).is_ok_and(|source| !source.imports.is_empty()) {
332        return parse_graph_input(&text, limits);
333    }
334    // Preserve the actual I/O cause and path instead of the generic resolver's
335    // display-only Load error. Reuse the root text on the first resolver call.
336    let mut root_text = Some(text);
337    let mut read_error = None;
338    let result = program::load(
339        path,
340        &mut |source: &Path| {
341            if let Some(text) = root_text.take() {
342                return Ok(text);
343            }
344            std::fs::read_to_string(source).map_err(|error| {
345                let error = Arc::new(error);
346                read_error = Some(BlockGraphError::Io {
347                    path: source.to_path_buf(),
348                    source: error.clone(),
349                });
350                error
351            })
352        },
353        limits,
354    );
355    if let Some(error) = read_error {
356        return Err(error);
357    }
358    result.map_err(|error| BlockGraphError::ModuleSource(Arc::new(error)))
359}
360
361/// Lowers parsed BLOG while deferring stabilizer-derived action dependencies.
362///
363/// # Errors
364///
365/// Returns an error if the AST cannot be represented as a block graph.
366pub fn lower_blog_ast_deferred(
367    source: &ast::SourceFile,
368    inputs: impl IntoIterator<Item = String>,
369) -> Result<BlockGraph, ParseError> {
370    lower::lower_deferred_with_inputs(source, inputs)
371}
372
373/// Lower editor drafts with checked geometry/syntax and lenient actions.
374/// Constraint failures remain in [`BlockGraph::action_graph_error`].
375///
376/// # Errors
377///
378/// Returns an error if geometry, names, or syntax cannot be lowered safely.
379pub fn lower_blog_ast_lenient(
380    source: &ast::SourceFile,
381    inputs: impl IntoIterator<Item = String>,
382) -> Result<BlockGraph, ParseError> {
383    lower::lower_lenient_with_inputs(source, inputs)
384}
385
386/// Lowers parsed modular BLOG while deferring stabilizer-derived dependencies.
387///
388/// # Errors
389///
390/// Returns an error if module structure, interfaces, or graph bodies are invalid.
391pub fn lower_blog_graph_ast_deferred(
392    source: &ast::ModularSourceFile,
393) -> Result<crate::BlockGraph, crate::ModuleError> {
394    program::lower_deferred(source)
395}
396
397/// Parse a resolved inline BLOG module hierarchy rooted at `module main`.
398///
399/// Use [`crate::load_graph`] when the source contains imports.
400///
401/// # Errors
402///
403/// Returns a parse or module-validation error for invalid input.
404#[cfg(test)]
405pub(crate) fn parse_inline_graph(input: &str) -> Result<crate::BlockGraph, crate::ModuleError> {
406    parse_inline_graph_with_limits(input, crate::ModuleCertificationLimits::DEFAULT)
407}
408
409/// Parse an inline BLOG hierarchy with explicit source-analysis and expansion limits.
410///
411/// # Errors
412///
413/// Returns a parse, module-validation, or resource-limit error for invalid input.
414pub(crate) fn parse_inline_graph_with_limits(
415    input: &str,
416    limits: crate::ModuleCertificationLimits,
417) -> Result<crate::BlockGraph, crate::ModuleError> {
418    program::lower(&parse::parse_program(input)?, limits)
419}
420
421/// Load BLOG with a caller-provided resolver and explicit resource limits.
422///
423/// # Errors
424///
425/// Returns a resolver, parse, import-resolution, module-validation, or resource-limit error.
426#[cfg(test)]
427pub(crate) fn load_graph_with_resolver_and_limits<E: std::fmt::Display>(
428    root: impl AsRef<Path>,
429    mut resolver: impl FnMut(&Path) -> Result<String, E>,
430    limits: crate::ModuleCertificationLimits,
431) -> Result<crate::BlockGraph, crate::ModuleError> {
432    program::load(root.as_ref(), &mut resolver, limits)
433}
434
435/// Parse BLOG source text into its [`ast::SourceFile`] without lowering.
436///
437/// # Errors
438///
439/// Returns [`ParseError::Syntax`] if the input is not valid BLOG syntax.
440///
441/// # Examples
442///
443/// ```
444/// let ast = bloq_graph::parse_blog_to_ast(
445///     "BLOG 1.0\n\n  0: ZXZ [0,0,0]\n",
446/// )?;
447/// assert_eq!(ast.data_stmts.len(), 1);
448/// # Ok::<(), bloq_graph::ParseError>(())
449/// ```
450pub fn parse_blog_to_ast(input: &str) -> Result<ast::SourceFile, ParseError> {
451    parse::parse(input)
452}
453
454/// Parse a BLOG module program without lowering it.
455///
456/// # Errors
457///
458/// Returns [`ParseError::Syntax`] if the input is not valid modular BLOG.
459pub fn parse_blog_program_to_ast(input: &str) -> Result<ast::ModularSourceFile, ParseError> {
460    parse::parse_program(input)
461}
462
463/// Parse an action snippet and lower it to `Action` values.
464///
465/// Used by the editor to parse action buffers that don't have the full BLOG
466/// file structure. `i2p` resolves a block ID to a position; the
467/// `Option<Direction>` argument requests the block anchor (`None`) or the
468/// endpoint facing a direction (`Some(dir)`), so measure-edge sources resolve
469/// the same way as the file-parse path.
470///
471/// # Errors
472///
473/// Returns an error for invalid syntax or an unresolved block reference.
474pub fn parse_actions(
475    input: &str,
476    i2p: impl Fn(u32, Option<Direction>) -> Option<IVec3>,
477) -> Result<Vec<Action>, ParseError> {
478    let stmts = parse::parse_actions_only(input)?;
479    lower::lower_actions(&stmts, i2p)
480}
481
482#[cfg(test)]
483mod tests {
484    use crate::ModuleCertificationLimits;
485
486    #[test]
487    fn explicit_limits_apply_to_imported_source_before_expansion() {
488        let limits = ModuleCertificationLimits {
489            max_expanded_blocks: 1,
490            ..ModuleCertificationLimits::DEFAULT
491        };
492        let root = "BLOG 1.0\nimport \"child.blog\" as child\nmodule main {\n0: ZXZ [0,0,0]\n}\n";
493        let child = "BLOG 1.0\nmodule main {\n0: ZXZ [0,0,0]\n1: ZXZ [0,0,1]\n0 -> +Z\n}\n";
494        let load = |limits| {
495            super::load_graph_with_resolver_and_limits(
496                "root.blog",
497                |path| match path.to_str() {
498                    Some("root.blog") => Ok(root.to_owned()),
499                    Some("child.blog") => Ok(child.to_owned()),
500                    _ => Err("unexpected import"),
501                },
502                limits,
503            )
504        };
505        let error = load(limits).expect_err("the imported source exceeds its block budget");
506        let message = error.to_string();
507        assert!(message.contains("2 > 1"), "{message}");
508        load(ModuleCertificationLimits::DEFAULT).expect("default limits load the same hierarchy");
509    }
510
511    #[test]
512    fn diagnostic_uses_byte_spans_after_unicode() {
513        let source = "BLOG 1.0\n# 中文中文中文中文中文\n0: INVALID [0,0,0]\n";
514        let error = super::parse_blog_body(source).unwrap_err();
515        let plain = error.render_diagnostic_plain("unicode.blog", source);
516        assert!(plain.contains("unicode.blog:3:"), "{plain}");
517        assert!(plain.contains("INVALID [0,0,0]"), "{plain}");
518        assert!(plain.contains("here"), "{plain}");
519        assert!(!plain.contains('\x1b'));
520    }
521}