Skip to main content

tdsl_parser/
error.rs

1use miette::{Diagnostic, NamedSource, SourceSpan};
2use thiserror::Error;
3
4#[derive(Debug, Error)]
5pub enum ParseError {
6    #[error("Syntax error: {0}")]
7    Syntax(#[from] pest::error::Error<crate::Rule>),
8
9    #[error("Invalid integer at {location}: {value}")]
10    InvalidInt { value: String, location: String },
11
12    #[error("Unknown re-import policy: {0}")]
13    UnknownPolicy(String),
14
15    #[error("Unknown map target type '{0}' (expected one of: span, event, event_range)")]
16    UnknownTargetType(String),
17
18    #[error("Unexpected rule {rule} at {location}")]
19    UnexpectedRule { rule: String, location: String },
20
21    #[error("Invalid month at {location}: {value} (expected 1-12)")]
22    InvalidMonth { value: u32, location: String },
23
24    #[error("Invalid day at {location}: {value} (expected 1-31)")]
25    InvalidDay { value: u32, location: String },
26}
27
28/// miette の fancy レポート(キャレット付きスニペット)を生成するための診断ラッパー。
29///
30/// CLI 層でのみ使用する。ライブラリ API では `ParseError` を直接返す。
31/// `source` テキストを添付することで miette がキャレット行を表示できる。
32#[derive(Debug, Error, Diagnostic)]
33#[error("{message}")]
34#[diagnostic(
35    code(tdsl::parse_error),
36    help("DSL 仕様書 docs/dsl-spec.md を確認してください")
37)]
38pub struct ParseDiagnostic {
39    message: String,
40    #[source_code]
41    src: NamedSource<String>,
42    #[label("ここに問題があります")]
43    span: Option<SourceSpan>,
44}
45
46impl ParseDiagnostic {
47    /// `ParseError` と DSL ソース文字列からキャレット付き診断を構築する。
48    ///
49    /// - `Syntax` variant: pest の `variant.message()` から簡潔な説明文のみを取得する。
50    ///   位置・スニペットは miette のキャレット描画に委ねる(pest の整形文字列は使わない)。
51    /// - バイトオフセット variant(`InvalidInt` 等): `location` フィールドの `"start:end"` 文字列を使う。
52    /// - 位置情報のない variant(`UnknownPolicy` 等): スパンなしで表示する。
53    pub fn from_parse_error(err: &ParseError, src: &str, filename: &str) -> Self {
54        let message = match err {
55            // pest の完全整形文字列(位置・スニペット込み)は使わず、
56            // variant.message() で "expected ..." の一行説明だけを取り出す。
57            ParseError::Syntax(pest_err) => {
58                format!("構文エラー: {}", pest_err.variant.message())
59            }
60            other => other.to_string(),
61        };
62        let named_src = NamedSource::new(filename, src.to_owned());
63
64        let span = Self::extract_span(err, src);
65
66        ParseDiagnostic {
67            message,
68            src: named_src,
69            span,
70        }
71    }
72
73    fn extract_span(err: &ParseError, src: &str) -> Option<SourceSpan> {
74        match err {
75            ParseError::Syntax(pest_err) => {
76                use pest::error::InputLocation;
77                match pest_err.location {
78                    InputLocation::Pos(offset) => {
79                        // 単一位置: 長さ 1 のスパン(それ以上は文脈がないため)
80                        let offset = offset.min(src.len().saturating_sub(1));
81                        Some(SourceSpan::from((offset, 1usize)))
82                    }
83                    InputLocation::Span((start, end)) => {
84                        let start = start.min(src.len());
85                        let end = end.min(src.len());
86                        let len = end.saturating_sub(start).max(1);
87                        Some(SourceSpan::from((start, len)))
88                    }
89                }
90            }
91            ParseError::InvalidInt { location, .. }
92            | ParseError::UnexpectedRule { location, .. }
93            | ParseError::InvalidMonth { location, .. }
94            | ParseError::InvalidDay { location, .. } => parse_byte_range_to_span(location, src),
95            ParseError::UnknownPolicy(_) | ParseError::UnknownTargetType(_) => None,
96        }
97    }
98
99    /// `SourceSpan` を返す(テストおよびカスタム表示向け)。
100    pub fn span(&self) -> Option<SourceSpan> {
101        self.span
102    }
103}
104
105/// `"start:end"` 形式のバイトオフセット文字列を `SourceSpan` に変換する(内部ヘルパ)。
106fn parse_byte_range_to_span(location: &str, src: &str) -> Option<SourceSpan> {
107    let (start_str, end_str) = location.split_once(':')?;
108    let start_byte: usize = start_str.trim().parse().ok()?;
109    let end_byte: usize = end_str.trim().parse().ok()?;
110    let start = start_byte.min(src.len());
111    let end = end_byte.min(src.len());
112    let len = end.saturating_sub(start).max(1);
113    Some(SourceSpan::from((start, len)))
114}
115
116/// DSL ソース内の位置情報(1-based 行番号・列番号)。
117/// LSP や WASM バインディングで診断位置を返すために使用する。
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct ParseErrorLoc {
120    /// 開始行(1-based)。
121    pub line: u32,
122    /// 開始列(1-based、バイト単位)。
123    pub col: u32,
124    /// 終了行(1-based)。開始と同じ行のことが多い。
125    pub end_line: u32,
126    /// 終了列(1-based、バイト単位)。
127    pub end_col: u32,
128}
129
130impl ParseError {
131    /// パースエラーのソース位置を返す。
132    ///
133    /// - `Syntax` variant は pest の `line_col` から直接取得する。
134    /// - バイトオフセット variant(`InvalidInt` / `InvalidMonth` / `InvalidDay` /
135    ///   `UnexpectedRule`)は `location` フィールドの `"start:end"` 文字列と
136    ///   `src` を使ってバイトオフセット→行列に変換する。
137    /// - `UnknownPolicy` / `UnknownTargetType` は位置情報を持たないため `None`。
138    pub fn source_location(&self, src: &str) -> Option<ParseErrorLoc> {
139        match self {
140            ParseError::Syntax(e) => {
141                use pest::error::LineColLocation;
142                match e.line_col {
143                    LineColLocation::Pos((line, col)) => Some(ParseErrorLoc {
144                        line: line as u32,
145                        col: col as u32,
146                        end_line: line as u32,
147                        end_col: col as u32,
148                    }),
149                    LineColLocation::Span((sl, sc), (el, ec)) => Some(ParseErrorLoc {
150                        line: sl as u32,
151                        col: sc as u32,
152                        end_line: el as u32,
153                        end_col: ec as u32,
154                    }),
155                }
156            }
157            ParseError::InvalidInt { location, .. }
158            | ParseError::UnexpectedRule { location, .. }
159            | ParseError::InvalidMonth { location, .. }
160            | ParseError::InvalidDay { location, .. } => byte_range_to_loc(location, src),
161            ParseError::UnknownPolicy(_) | ParseError::UnknownTargetType(_) => None,
162        }
163    }
164}
165
166/// `"start:end"` 形式のバイトオフセット文字列からソース位置に変換する(内部ヘルパ)。
167fn byte_range_to_loc(location: &str, src: &str) -> Option<ParseErrorLoc> {
168    let (start_str, end_str) = location.split_once(':')?;
169    let start_byte: usize = start_str.trim().parse().ok()?;
170    let end_byte: usize = end_str.trim().parse().ok()?;
171
172    let (start_line, start_col) = byte_offset_to_line_col(src, start_byte);
173    let (end_line, end_col) = byte_offset_to_line_col(src, end_byte);
174
175    Some(ParseErrorLoc {
176        line: start_line,
177        col: start_col,
178        end_line,
179        end_col,
180    })
181}
182
183/// バイトオフセットを 1-based の (line, col) に変換する。
184///
185/// pest の span はバイト単位かつ char 境界に揃っているため `src` のスライスは安全。
186/// LSP など、AST ノードの `Span`(バイトオフセット)から行・列を求める用途でも再利用する。
187pub fn byte_offset_to_line_col(src: &str, offset: usize) -> (u32, u32) {
188    // オフセットがソース長を超えていたら末尾に丸める
189    let offset = offset.min(src.len());
190    let before = &src[..offset];
191    let line = (before.chars().filter(|&c| c == '\n').count() + 1) as u32;
192    let col = (before.rfind('\n').map_or(offset, |pos| offset - pos - 1) + 1) as u32;
193    (line, col)
194}