Skip to content

Commit 2d3f58d

Browse files
authored
refactor: Improve diagnostics (#158)
1 parent e0eff20 commit 2d3f58d

23 files changed

Lines changed: 559 additions & 309 deletions

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ test:
1010
@cargo nextest run --no-fail-fast --hide-progress-bar --status-level none --failure-output final
1111

1212
shot:
13+
@# See AGENTS.md for diagnostic guidelines
14+
@cargo nextest run --no-fail-fast --hide-progress-bar --status-level none --failure-output final || true
1315
@cargo insta accept
16+
@cargo nextest run --no-fail-fast --hide-progress-bar --status-level none --failure-output final
1417

1518
coverage-lines:
1619
@cargo llvm-cov --package plotnik-lib --text --show-missing-lines 2>/dev/null | grep '\.rs: [0-9]' | sed 's|.*/crates/|crates/|'

crates/plotnik-lib/src/diagnostics/message.rs

Lines changed: 55 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -129,37 +129,57 @@ impl DiagnosticKind {
129129
matches!(self, Self::UnnamedDef)
130130
}
131131

132+
/// Default hint for this kind, automatically included in diagnostics.
133+
/// Call sites can add additional hints for context-specific information.
134+
pub fn default_hint(&self) -> Option<&'static str> {
135+
match self {
136+
Self::ExpectedSubtype => Some("e.g., `expression/binary_expression`"),
137+
Self::ExpectedTypeName => Some("e.g., `::MyType` or `::string`"),
138+
Self::ExpectedFieldName => Some("e.g., `!value`"),
139+
Self::EmptyTree => Some("use `(_)` to match any named node, or `_` for any node"),
140+
Self::TreeSitterSequenceSyntax => Some("use `{...}` for sequences"),
141+
Self::MixedAltBranches => {
142+
Some("use all labels for a tagged union, or none for a merged struct")
143+
}
144+
Self::RecursionNoEscape => {
145+
Some("add a non-recursive branch to terminate: `[Base: ... Rec: (Self)]`")
146+
}
147+
Self::DirectRecursion => {
148+
Some("recursive references must consume input before recursing")
149+
}
150+
_ => None,
151+
}
152+
}
153+
132154
/// Base message for this diagnostic kind, used when no custom message is provided.
133155
pub fn fallback_message(&self) -> &'static str {
134156
match self {
135-
// Unclosed delimiters - clear about what's missing
157+
// Unclosed delimiters
136158
Self::UnclosedTree => "missing closing `)`",
137159
Self::UnclosedSequence => "missing closing `}`",
138160
Self::UnclosedAlternation => "missing closing `]`",
139161

140-
// Expected token errors - specific about what's needed
162+
// Expected token errors
141163
Self::ExpectedExpression => "expected an expression",
142-
Self::ExpectedTypeName => "expected type name after `::`",
143-
Self::ExpectedCaptureName => "expected name after `@`",
164+
Self::ExpectedTypeName => "expected type name",
165+
Self::ExpectedCaptureName => "expected capture name",
144166
Self::ExpectedFieldName => "expected field name",
145-
Self::ExpectedSubtype => "expected subtype after `/`",
146-
147-
// Invalid syntax - explain what's wrong
148-
Self::EmptyTree => "empty parentheses are not allowed",
149-
Self::BareIdentifier => "bare identifier is not a valid expression",
150-
Self::InvalidSeparator => "separators are not needed",
151-
Self::InvalidFieldEquals => "use `:` for field constraints, not `=`",
152-
Self::InvalidSupertypeSyntax => "supertype syntax not allowed on references",
153-
Self::InvalidTypeAnnotationSyntax => "use `::` for type annotations, not `:`",
154-
Self::ErrorTakesNoArguments => "`(ERROR)` cannot have child nodes",
167+
Self::ExpectedSubtype => "expected subtype name",
168+
169+
// Invalid syntax
170+
Self::EmptyTree => "empty `()` is not allowed",
171+
Self::BareIdentifier => "bare identifier is not valid",
172+
Self::InvalidSeparator => "unexpected separator",
173+
Self::InvalidFieldEquals => "use `:` instead of `=`",
174+
Self::InvalidSupertypeSyntax => "references cannot have supertypes",
175+
Self::InvalidTypeAnnotationSyntax => "use `::` for type annotations",
176+
Self::ErrorTakesNoArguments => "`(ERROR)` cannot have children",
155177
Self::RefCannotHaveChildren => "references cannot have children",
156-
Self::ErrorMissingOutsideParens => {
157-
"`ERROR` and `MISSING` must be wrapped in parentheses"
158-
}
159-
Self::UnsupportedPredicate => "predicates like `#match?` are not supported",
178+
Self::ErrorMissingOutsideParens => "special node requires parentheses",
179+
Self::UnsupportedPredicate => "predicates are not supported",
160180
Self::UnexpectedToken => "unexpected token",
161-
Self::CaptureWithoutTarget => "`@` must follow an expression to capture",
162-
Self::LowercaseBranchLabel => "branch labels must be capitalized",
181+
Self::CaptureWithoutTarget => "capture has no target",
182+
Self::LowercaseBranchLabel => "branch label must start with uppercase",
163183

164184
// Naming convention violations
165185
Self::CaptureNameHasDots => "capture names cannot contain `.`",
@@ -172,23 +192,25 @@ impl DiagnosticKind {
172192
Self::FieldNameHasHyphens => "field names cannot contain `-`",
173193
Self::FieldNameUppercase => "field names must be lowercase",
174194
Self::TypeNameInvalidChars => "type names cannot contain `.` or `-`",
175-
Self::TreeSitterSequenceSyntax => "Tree-sitter sequence syntax",
195+
Self::TreeSitterSequenceSyntax => "tree-sitter sequence syntax",
176196

177197
// Semantic errors
178-
Self::DuplicateDefinition => "name already defined",
198+
Self::DuplicateDefinition => "duplicate definition",
179199
Self::UndefinedReference => "undefined reference",
180200
Self::MixedAltBranches => "cannot mix labeled and unlabeled branches",
181-
Self::RecursionNoEscape => "infinite recursion: cycle has no escape path",
201+
Self::RecursionNoEscape => "infinite recursion: no escape path",
182202
Self::DirectRecursion => "infinite recursion: cycle consumes no input",
183-
Self::FieldSequenceValue => "field must match exactly one node",
203+
Self::FieldSequenceValue => "field cannot match a sequence",
184204

185205
// Type inference
186-
Self::IncompatibleTypes => "incompatible types in alternation branches",
206+
Self::IncompatibleTypes => "incompatible types",
187207
Self::MultiCaptureQuantifierNoName => {
188-
"quantified expression with multiple captures requires `@name`"
208+
"quantified expression with multiple captures requires a struct capture"
189209
}
190210
Self::UnusedBranchLabels => "branch labels have no effect without capture",
191-
Self::StrictDimensionalityViolation => "quantifier requires row capture",
211+
Self::StrictDimensionalityViolation => {
212+
"quantifier with captures requires a struct capture"
213+
}
192214
Self::DuplicateCaptureInScope => "duplicate capture in scope",
193215
Self::IncompatibleCaptureTypes => "incompatible capture types",
194216
Self::IncompatibleStructShapes => "incompatible struct shapes",
@@ -201,7 +223,7 @@ impl DiagnosticKind {
201223
Self::InvalidChildType => "node type not valid as child",
202224

203225
// Structural
204-
Self::UnnamedDef => "definitions must be named",
226+
Self::UnnamedDef => "definition must be named",
205227
}
206228
}
207229

@@ -212,9 +234,7 @@ impl DiagnosticKind {
212234
Self::RefCannotHaveChildren => {
213235
"`{}` is a reference and cannot have children".to_string()
214236
}
215-
Self::FieldSequenceValue => {
216-
"field `{}` must match exactly one node, not a sequence".to_string()
217-
}
237+
Self::FieldSequenceValue => "field `{}` cannot match a sequence".to_string(),
218238

219239
// Semantic errors with name context
220240
Self::DuplicateDefinition => "`{}` is already defined".to_string(),
@@ -249,15 +269,13 @@ impl DiagnosticKind {
249269
}
250270

251271
// Type annotation specifics
252-
Self::InvalidTypeAnnotationSyntax => {
253-
"type annotations use `::`, not `:` — {}".to_string()
254-
}
272+
Self::InvalidTypeAnnotationSyntax => "use `::` for type annotations: {}".to_string(),
255273

256-
// Named def
257-
Self::UnnamedDef => "definitions must be named — {}".to_string(),
274+
// Named def (no custom message needed; suggestion goes in hint)
275+
Self::UnnamedDef => self.fallback_message().to_string(),
258276

259277
// Standard pattern: fallback + context
260-
_ => format!("{}; {{}}", self.fallback_message()),
278+
_ => format!("{}: {{}}", self.fallback_message()),
261279
}
262280
}
263281

crates/plotnik-lib/src/diagnostics/mod.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,11 @@ impl<'a> DiagnosticBuilder<'a> {
245245
self
246246
}
247247

248-
pub fn emit(self) {
248+
pub fn emit(mut self) {
249+
// Prepend default hint if one exists for this kind
250+
if let Some(default_hint) = self.message.kind.default_hint() {
251+
self.message.hints.insert(0, default_hint.to_string());
252+
}
249253
self.diagnostics.messages.push(self.message);
250254
}
251255
}

crates/plotnik-lib/src/diagnostics/tests.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ fn builder_with_fix() {
8989

9090
let result = diagnostics.printer(&map).render();
9191
insta::assert_snapshot!(result, @r"
92-
error: use `:` for field constraints, not `=`; fixable
92+
error: use `:` instead of `=`: fixable
9393
|
9494
1 | hello world
9595
| ^^^^^
@@ -207,7 +207,7 @@ fn printer_zero_width_span() {
207207

208208
let result = diagnostics.printer(&map).render();
209209
insta::assert_snapshot!(result, @r"
210-
error: expected an expression; zero width error
210+
error: expected an expression: zero width error
211211
|
212212
1 | hello
213213
| ^

crates/plotnik-lib/src/parser/core.rs

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,7 @@ impl<'src, 'diag> Parser<'src, 'diag> {
260260
true
261261
}
262262

263-
fn bump_as_error(&mut self) {
263+
pub(super) fn bump_as_error(&mut self) {
264264
if !self.eof() {
265265
self.start_node(SyntaxKind::Error);
266266
self.bump();
@@ -298,11 +298,32 @@ impl<'src, 'diag> Parser<'src, 'diag> {
298298
.emit();
299299
}
300300

301+
pub(super) fn error_with_hint(&mut self, kind: DiagnosticKind, hint: impl Into<String>) {
302+
let Some((range, suppression)) = self.get_error_ranges() else {
303+
return;
304+
};
305+
self.diagnostics
306+
.report(self.source_id, kind, range)
307+
.hint(hint)
308+
.suppression_range(suppression)
309+
.emit();
310+
}
311+
301312
pub(super) fn error_and_bump(&mut self, kind: DiagnosticKind) {
302313
self.error(kind);
303314
self.bump_as_error();
304315
}
305316

317+
pub(super) fn error_and_bump_with_hint(
318+
&mut self,
319+
kind: DiagnosticKind,
320+
hint: impl Into<String>,
321+
) {
322+
self.error_with_hint(kind, hint);
323+
self.bump_as_error();
324+
}
325+
326+
#[allow(dead_code)]
306327
pub(super) fn error_and_bump_msg(&mut self, kind: DiagnosticKind, message: impl Into<String>) {
307328
self.error_msg(kind, message);
308329
self.bump_as_error();
@@ -359,7 +380,6 @@ impl<'src, 'diag> Parser<'src, 'diag> {
359380
pub(super) fn error_unclosed_delimiter(
360381
&mut self,
361382
kind: DiagnosticKind,
362-
message: impl Into<String>,
363383
related_msg: impl Into<String>,
364384
open_range: TextRange,
365385
) {
@@ -371,7 +391,6 @@ impl<'src, 'diag> Parser<'src, 'diag> {
371391
let full_range = TextRange::new(open_range.start(), current.end());
372392
self.diagnostics
373393
.report(self.source_id, kind, full_range)
374-
.message(message)
375394
.related_to(self.source_id, open_range, related_msg)
376395
.emit();
377396
}

crates/plotnik-lib/src/parser/grammar/expressions.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ impl Parser<'_, '_> {
2222
return false;
2323
}
2424

25-
self.error_and_bump_msg(
25+
self.error_and_bump_with_hint(
2626
DiagnosticKind::UnexpectedToken,
2727
"try `(node)`, `[a b]`, `{a b}`, `\"literal\"`, or `_`",
2828
);
@@ -65,7 +65,7 @@ impl Parser<'_, '_> {
6565
self.error_and_bump(DiagnosticKind::ErrorMissingOutsideParens);
6666
}
6767
_ => {
68-
self.error_and_bump_msg(DiagnosticKind::UnexpectedToken, "not a valid expression");
68+
self.error_and_bump(DiagnosticKind::UnexpectedToken);
6969
}
7070
}
7171

crates/plotnik-lib/src/parser/grammar/fields.rs

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,7 @@ impl Parser<'_, '_> {
4242
self.bump();
4343
self.validate_type_name(text, span);
4444
} else {
45-
self.error_msg(
46-
DiagnosticKind::ExpectedTypeName,
47-
"e.g., `::MyType` or `::string`",
48-
);
45+
self.error(DiagnosticKind::ExpectedTypeName);
4946
}
5047

5148
self.finish_node();
@@ -84,7 +81,7 @@ impl Parser<'_, '_> {
8481
self.expect(SyntaxKind::Negation, "'!' for negated field");
8582

8683
if !self.currently_is(SyntaxKind::Id) {
87-
self.error_msg(DiagnosticKind::ExpectedFieldName, "e.g., `!value`");
84+
self.error(DiagnosticKind::ExpectedFieldName);
8885
self.finish_node();
8986
return;
9087
}
@@ -110,10 +107,13 @@ impl Parser<'_, '_> {
110107
}
111108

112109
// Bare identifiers are not valid expressions; trees require parentheses
113-
self.error_and_bump_msg(
114-
DiagnosticKind::BareIdentifier,
115-
"wrap in parentheses: `(identifier)`",
116-
);
110+
let span = self.current_span();
111+
let text = token_text(self.source, &self.tokens[self.pos]);
112+
self.diagnostics
113+
.report(self.source_id, DiagnosticKind::BareIdentifier, span)
114+
.fix("wrap in parentheses", format!("({})", text))
115+
.emit();
116+
self.bump_as_error();
117117
}
118118

119119
/// Field constraint: `field_name: expr`
@@ -134,7 +134,7 @@ impl Parser<'_, '_> {
134134
if self.currently_is_one_of(EXPR_FIRST_TOKENS) {
135135
self.parse_expr_no_suffix();
136136
} else {
137-
self.error_msg(DiagnosticKind::ExpectedExpression, "after `field:`");
137+
self.error(DiagnosticKind::ExpectedExpression);
138138
}
139139

140140
self.finish_node();
@@ -158,7 +158,7 @@ impl Parser<'_, '_> {
158158
if self.currently_is_one_of(EXPR_FIRST_TOKENS) {
159159
self.parse_expr();
160160
} else {
161-
self.error_msg(DiagnosticKind::ExpectedExpression, "after `field =`");
161+
self.error(DiagnosticKind::ExpectedExpression);
162162
}
163163

164164
self.finish_node();

crates/plotnik-lib/src/parser/grammar/items.rs

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ impl Parser<'_, '_> {
3030
let def_text = &self.source[usize::from(start)..usize::from(end)];
3131
self.diagnostics
3232
.report(self.source_id, DiagnosticKind::UnnamedDef, span)
33-
.message(format!("give it a name like `Name = {}`", def_text.trim()))
33+
.hint(format!("give it a name like `Name = {}`", def_text.trim()))
3434
.emit();
3535
}
3636
}
@@ -83,10 +83,7 @@ impl Parser<'_, '_> {
8383
if self.currently_is_one_of(EXPR_FIRST_TOKENS) {
8484
self.parse_expr();
8585
} else {
86-
self.error_msg(
87-
DiagnosticKind::ExpectedExpression,
88-
"after `=` in definition",
89-
);
86+
self.error(DiagnosticKind::ExpectedExpression);
9087
}
9188

9289
self.finish_node();

0 commit comments

Comments
 (0)