Skip to content

slicec Diagnostic Refactoring#772

Open
InsertCreativityHere wants to merge 5 commits intoicerpc:mainfrom
InsertCreativityHere:big-diagnostic-refactoring
Open

slicec Diagnostic Refactoring#772
InsertCreativityHere wants to merge 5 commits intoicerpc:mainfrom
InsertCreativityHere:big-diagnostic-refactoring

Conversation

@InsertCreativityHere
Copy link
Copy Markdown
Member

@InsertCreativityHere InsertCreativityHere commented Apr 16, 2026

Right now we use the same Diagnostic type to report errors as we do to emit them.

However, these are really 2 different purposes, which our API reflects. Diagnostics requires some post-processing before it's correct to emit them, so our API keeps them under lock-and-key, and has functions (like into_updated) which internally update it's fields to "convert" from one form to another.

So, instead of using 1 type for 2 purposes, with 2 different internal "states" (ready to emit vs not)... better to just have 2 different types, with a domain-separation approach. This PR replaces our current design by splitting Diagnostic in 2:

  • Diagnostic is what the validation/parsing creates when an error or something happens.
  • PrintableDiagnostic contains a patched & updated version of Diagnostic which is ready to be emitted to the user.

@InsertCreativityHere InsertCreativityHere changed the title `slicec Diagnostic Refactoring slicec Diagnostic Refactoring Apr 16, 2026
pub fn parse_multiple_for_ast(slice: &[&str]) -> Ast {
let compilation_state = compile_from_strings(slice, None);
if compilation_state.diagnostics.has_errors() {
if !compilation_state.diagnostics.is_empty() {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests should fail if there's any diagnostics, not just errors.
If there were warnings in our tests, they would be silently ignored with the old logic.

Comment on lines +120 to +124
// If a scope was provided, check that it matches.
if expect.scope.is_some() && expect.scope != diagnostic.scope {
eprintln!("diagnostic scopes didn't match:");
eprintln!("\texpected: \"{:?}\"", expect.scope);
eprintln!("\t but got: \"{:?}\"", diagnostic.scope);
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We weren't checking that diagnostics were thrown the Slice scope we expected, when we really should be checking this. Thankfully there were no bugs to be caught :) But should still be checking!

Comment thread slicec/examples/parse.rs
@@ -1,11 +0,0 @@
// Copyright (c) ZeroC, Inc.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had this outdated example laying around, from when slicec was library only. The example shows how to run slicec as a binary... But now that's actually a binary, this example is pointless.

#[derive(Debug)]
pub struct Diagnostic {
kind: DiagnosticKind,
level: DiagnosticLevel,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DIagnosticLevel is the "patched" level, which is only useful for printing the diagnostic.

i.e. a lint can become either a warning, or nothing if it's allowed.
We also plan in the future to add opt-in lints which can be upgraded to warnings.

Comment thread slicec/src/parsers/mod.rs

// Store any diagnostics that were emitted during parsing.
state.diagnostics.extend(diagnostics);
state.diagnostics.extend(diagnostics.into_inner());
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We used to have our own Diagnostics::extend function, which I removed. Now we're just using the normal Vec::extend, but in this one spot, it requires an extra call to get the right type.

pub struct DiagnosticEmitter<'a> {
/// Reference to the output that diagnostics should be emitted to.
output: &'a mut T,
output: &'a mut dyn Write,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched from using a generic T: Write to a dynamic dyn Write.
This makes the typing for DiagnosticEmitter easier, since it no longer has a generic type.

And in reality, it's not like we care about DiagnosticEmitter<StdOut> vs DiagnosticEmitter<Vec<u8>>...
As long as we can write to something it's fine. No need to tie the underlying write-able thing to the type of DiagnosticEmitter.

Comment on lines -17 to -18
/// If true, diagnostic output will not be styled with colors (only used in `human` format).
disable_color: bool,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We disable color way earlier in the program now, instead of right before diagnostic emission.
Otherwise there were other things still being emitted in color.

if self.disable_color {
console::set_colors_enabled(false);
console::set_colors_enabled_stderr(false);
pub fn get_totals(diagnostics: &[PrintableDiagnostic]) -> (usize, usize) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to be a free function at the bottom of diagnostic.rs, now it's a static function on DiagnosticEmitter.
Just feels tidier to keep it here, since this was only used when emitting diagnostics.

(total_warnings, total_errors)
}

pub fn emit_totals(total_warnings: usize, total_errors: usize) -> Result<()> {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to be a free function at the bottom of this file, now it's a static function on DiagnosticEmitter.
Just feels tidier, and saves an import in the places it's used.

@@ -124,16 +126,17 @@ mod attributes {
message: "comment has a 'returns' tag, but only operations can return".to_owned(),
}),
];
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the old test setup, "allowed" lints were removed entirely.

With the new test setup, we make sure that the lints were emitted, but that they are correctly marked as Allowed instead of Warning. This feels like a safer and more true-to-task test.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors slicec diagnostics by separating “internal” diagnostics produced during parsing/validation from “emission-ready” diagnostics, introducing a new PrintableDiagnostic type that contains computed levels and pre-extracted snippets suitable for user output.

Changes:

  • Introduces PrintableDiagnostic conversion and updates CompilationState to produce printable diagnostics for emission.
  • Refactors DiagnosticEmitter to emit &[PrintableDiagnostic] (human + JSON) and updates CLI/tests accordingly.
  • Adjusts various validators/parsers/tests to work with the new diagnostic representation and APIs.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
slicec/tests/test_helpers.rs Updates test helpers to work with refactored diagnostics storage/access patterns.
slicec/tests/diagnostic_output_tests.rs Switches output tests to use get_printable_diagnostics and new emitter signature.
slicec/tests/attribute_tests.rs Updates allow-lint tests to assert against printable diagnostics (level changes).
slicec/src/validators/dictionary.rs Adapts note/span handling to new Diagnostic field access patterns.
slicec/src/validators/cycle_detection.rs Refactors cycle diagnostic construction without removed fluent APIs.
slicec/src/parsers/mod.rs Updates diagnostics accumulation to use into_inner() with the refactored container.
slicec/src/main.rs Refactors CLI emission flow to use printable diagnostics and updated emitter API.
slicec/src/lib.rs Adds color-disabling behavior during compilation entry points.
slicec/src/diagnostics/printable_diagnostic.rs New conversion logic from Diagnostic to PrintableDiagnostic (level + snippet extraction).
slicec/src/diagnostics/mod.rs Exposes new module/types and relocates DiagnosticKind/DiagnosticLevel.
slicec/src/diagnostics/diagnostic.rs Simplifies Diagnostic to “internal” form and adjusts Diagnostics container API.
slicec/src/diagnostic_emitter.rs Refactors emitter to consume PrintableDiagnostic and moves totals logic into the emitter.
slicec/src/compilation_state.rs Adds get_printable_diagnostics as the primary “ready to emit” diagnostics accessor.
slicec/examples/parse.rs Removes example that relied on the removed emit_diagnostics API.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread slicec/tests/test_helpers.rs
Comment thread slicec/tests/attribute_tests.rs
Comment on lines +93 to +94
if is_lint_allowed_by_attributes(file.unwrap(), lint) {
return DiagnosticLevel::Allowed;
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

file.unwrap() can panic if a diagnostic span references a file that is not present in compilation_state.files (e.g., if diagnostics are constructed from external inputs). This also removes the more informative panic message that previously existed in the old code path. Prefer handling the None case gracefully (treat as not allowed) or use expect("...") with a clear message to preserve debuggability.

Suggested change
if is_lint_allowed_by_attributes(file.unwrap(), lint) {
return DiagnosticLevel::Allowed;
if let Some(file) = file {
if is_lint_allowed_by_attributes(file, lint) {
return DiagnosticLevel::Allowed;
}

Copilot uses AI. Check for mistakes.
Comment thread slicec/src/diagnostic_emitter.rs
Comment thread slicec/src/lib.rs
Comment on lines +27 to +31
// Disable colored output if the user requested we do so.
if options.disable_color {
console::set_colors_enabled(false);
console::set_colors_enabled_stderr(false);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling console::set_colors_enabled(false) here changes a global process-wide setting and is never reverted when disable_color is false in subsequent compilations. Since slicec is a library as well as a CLI, this can leak configuration across independent compile calls/tests. Consider moving color toggling back to the emission layer, or explicitly setting colors enabled/disabled based on the passed options each time.

Copilot uses AI. Check for mistakes.
Comment thread slicec/src/lib.rs
Comment on lines +47 to +51
// Disable colored output if the user requested we do so.
if options.map(|opts| opts.disable_color).unwrap_or_default() {
console::set_colors_enabled(false);
console::set_colors_enabled_stderr(false);
}
Copy link

Copilot AI Apr 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compile_from_strings also disables console colors via global setters, which can unintentionally affect later tests/compilations in the same process. If global toggling is required, consider restoring the previous value or explicitly setting enable/disable based on SliceOptions for each call to avoid cross-call leakage.

Copilot uses AI. Check for mistakes.
pub fn new() -> Self {
Self::default()
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this extra constructor, why not just use ::default?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need this extra constructor

Honestly, no.

why not just use ::default?

It just feels more semantically correct to create a 'new' Diagnostic, instead of a 'default' Diagnostic.
That was my feeling at least. If you don't like it though, I can remove or simplify! : ^)

/// Diagnostic levels describe the severity of a diagnostic, and how the compiler should react to their emission.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
pub enum DiagnosticLevel {
#[rustfmt::skip] // See https://github.com/rust-lang/rustfmt/issues/5801
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This issue is closed, presumably the fix should be in the nighty.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was just copy-pasted. Didn't even think to check, good idea.

Comment on lines +24 to +25
#[serde(rename = "span")]
pub snippet: Option<Snippet>,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we encode as "span" in JSON?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes it backwards compatible with the JSON decoding the tooling does.

But, it depended on what you answered to the TODO :) I'll update to send the full snippet.

pub span: Span,
pub text: String,
}
// TODO should we send the text snippet in JSON diagnostic format? Or just the range?
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just include it for simplicity. Right now all our Snippets are really just spans in JSON.

state.serialize_field("span", &diagnostic.span())?;
state.serialize_field("notes", diagnostic.notes())?;
state.serialize_field("error_code", diagnostic.code())?;
state.serialize_field("span", &diagnostic.snippet)?;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should either encode just the span like we did before (but as diagnostic.snippet.span`, or encode the entire snippet.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR currently does option 1.
If you look at the implementation where snippet is encoded, all it actually encodes is the span.

Personally, I'd lean towards option 2 (encode the full snippet (span + text),
but wasn't sure what other opinions would be.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants