feat: lint on bug-like show/set rules (#1634)

* feat: init linter

* feat: init structure

* feat: init tests

* feat: warn on bad set rules

* feat: refactor struct

* feat: run check

* chore: update message

* chore: add hints
This commit is contained in:
Myriad-Dreamin 2025-04-08 07:36:03 +08:00 committed by GitHub
parent ac506dcc31
commit d6bce89e68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 664 additions and 51 deletions

View file

@ -598,3 +598,27 @@ mod call_info_tests {
}
}
}
#[cfg(test)]
mod lint_tests {
use std::collections::BTreeMap;
use crate::tests::*;
#[test]
fn test() {
snapshot_testing("lint", &|ctx, path| {
let source = ctx.source_by_path(&path).unwrap();
let result = tinymist_lint::lint_source(&source);
let result =
crate::diagnostics::CheckDocWorker::new(&ctx.world, ctx.position_encoding())
.convert_all(result.iter());
let result = result
.into_iter()
.map(|(k, v)| (file_path_(&k), v))
.collect::<BTreeMap<_, _>>();
assert_snapshot!(JsonRepr::new_redacted(result, &REDACT_LOC));
});
}
}

View file

@ -1,7 +1,8 @@
use std::borrow::Cow;
use tinymist_project::LspWorld;
use typst::syntax::Span;
use tinymist_world::vfs::WorkspaceResolver;
use typst::{diag::SourceDiagnostic, syntax::Span};
use crate::{prelude::*, LspWorldExt};
@ -13,15 +14,29 @@ pub type DiagnosticsMap = HashMap<Url, EcoVec<Diagnostic>>;
type TypstDiagnostic = typst::diag::SourceDiagnostic;
type TypstSeverity = typst::diag::Severity;
/// Converts a list of Typst diagnostics to LSP diagnostics,
/// with potential refinements on the error messages.
pub fn check_doc<'a>(
world: &LspWorld,
errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
position_encoding: PositionEncoding,
) -> DiagnosticsMap {
CheckDocWorker::new(world, position_encoding)
.check()
.convert_all(errors)
}
/// Context for converting Typst diagnostics to LSP diagnostics.
struct LocalDiagContext<'a> {
pub(crate) struct CheckDocWorker<'a> {
/// The world surface for Typst compiler.
pub world: &'a LspWorld,
/// The position encoding for the source.
pub position_encoding: PositionEncoding,
/// Results
pub results: DiagnosticsMap,
}
impl std::ops::Deref for LocalDiagContext<'_> {
impl std::ops::Deref for CheckDocWorker<'_> {
type Target = LspWorld;
fn deref(&self) -> &Self::Target {
@ -29,37 +44,64 @@ impl std::ops::Deref for LocalDiagContext<'_> {
}
}
/// Converts a list of Typst diagnostics to LSP diagnostics,
/// with potential refinements on the error messages.
pub fn convert_diagnostics<'a>(
world: &LspWorld,
errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
position_encoding: PositionEncoding,
) -> DiagnosticsMap {
let ctx = LocalDiagContext {
world,
position_encoding,
};
let kvs = errors
.into_iter()
.flat_map(|error| {
convert_diagnostic(&ctx, error)
.map_err(move |conversion_err| {
log::error!("could not convert Typst error to diagnostic: {conversion_err:?} error to convert: {error:?}");
})
});
let mut lookup = HashMap::new();
for (key, val) in kvs {
lookup.entry(key).or_insert_with(EcoVec::new).push(val);
impl<'w> CheckDocWorker<'w> {
/// Creates a new `CheckDocWorker` instance.
pub fn new(world: &'w LspWorld, position_encoding: PositionEncoding) -> Self {
Self {
world,
position_encoding,
results: DiagnosticsMap::default(),
}
}
lookup
/// Runs code check on the document.
pub fn check(mut self) -> Self {
for dep in self.world.depended_files() {
if WorkspaceResolver::is_package_file(dep) {
continue;
}
let Ok(source) = self.world.source(dep) else {
continue;
};
let res = lint_source(&source);
if !res.is_empty() {
for diag in res {
self.handle(&diag);
}
}
}
self
}
/// Converts a list of Typst diagnostics to LSP diagnostics.
pub fn convert_all<'a>(
mut self,
errors: impl IntoIterator<Item = &'a TypstDiagnostic>,
) -> DiagnosticsMap {
for diag in errors {
self.handle(diag);
}
self.results
}
/// Converts a list of Typst diagnostics to LSP diagnostics.
pub fn handle(&mut self, diag: &TypstDiagnostic) {
match convert_diagnostic(self, diag) {
Ok((uri, diagnostic)) => {
self.results.entry(uri).or_default().push(diagnostic);
}
Err(error) => {
log::error!("Failed to convert Typst diagnostic: {error:?}");
}
}
}
}
fn convert_diagnostic(
ctx: &LocalDiagContext,
ctx: &CheckDocWorker,
typst_diagnostic: &TypstDiagnostic,
) -> anyhow::Result<(Url, Diagnostic)> {
let typst_diagnostic = {
@ -102,7 +144,7 @@ fn convert_diagnostic(
}
fn tracepoint_to_relatedinformation(
ctx: &LocalDiagContext,
ctx: &CheckDocWorker,
tracepoint: &Spanned<Tracepoint>,
position_encoding: PositionEncoding,
) -> anyhow::Result<Option<DiagnosticRelatedInformation>> {
@ -127,7 +169,7 @@ fn tracepoint_to_relatedinformation(
}
fn diagnostic_related_information(
project: &LocalDiagContext,
project: &CheckDocWorker,
typst_diagnostic: &TypstDiagnostic,
position_encoding: PositionEncoding,
) -> anyhow::Result<Vec<DiagnosticRelatedInformation>> {
@ -145,7 +187,7 @@ fn diagnostic_related_information(
}
fn diagnostic_span_id(
ctx: &LocalDiagContext,
ctx: &CheckDocWorker,
typst_diagnostic: &TypstDiagnostic,
) -> (TypstFileId, Span) {
iter::once(typst_diagnostic.span)
@ -238,3 +280,8 @@ impl DiagnosticRefiner for OutOfRootHintRefiner {
raw.with_hint("Cannot read file outside of project root.")
}
}
#[comemo::memoize]
fn lint_source(source: &Source) -> EcoVec<SourceDiagnostic> {
tinymist_lint::lint_source(source)
}

View file

@ -0,0 +1,6 @@
#if false {
set text(red)
}
123

View file

@ -0,0 +1,6 @@
#if false {
show: text(red)
}
123

View file

@ -0,0 +1,6 @@
#show: {
set text(red)
}
123

View file

@ -0,0 +1,6 @@
#show raw: {
set text(red)
}
123

View file

@ -0,0 +1,16 @@
---
source: crates/tinymist-query/src/analysis.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/lint/if_set.typ
---
{
"s0.typ": [
{
"message": "This set statement doesn't take effect.\nHint: consider changing parent to `set text(red) if (false)`",
"range": "1:2:1:15",
"relatedInformation": [],
"severity": 2,
"source": "typst"
}
]
}

View file

@ -0,0 +1,16 @@
---
source: crates/tinymist-query/src/analysis.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/lint/if_show.typ
---
{
"s0.typ": [
{
"message": "This show statement doesn't take effect.\nHint: consider changing parent to `show : if (false) { .. }`",
"range": "1:2:1:17",
"relatedInformation": [],
"severity": 2,
"source": "typst"
}
]
}

View file

@ -0,0 +1,16 @@
---
source: crates/tinymist-query/src/analysis.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/lint/show_set.typ
---
{
"s0.typ": [
{
"message": "This set statement doesn't take effect.\nHint: consider changing parent to `show : set text(red)`",
"range": "1:2:1:15",
"relatedInformation": [],
"severity": 2,
"source": "typst"
}
]
}

View file

@ -0,0 +1,16 @@
---
source: crates/tinymist-query/src/analysis.rs
expression: "JsonRepr::new_redacted(result, &REDACT_LOC)"
input_file: crates/tinymist-query/src/fixtures/lint/show_set2.typ
---
{
"s0.typ": [
{
"message": "This set statement doesn't take effect.\nHint: consider changing parent to `show raw: set text(red)`",
"range": "1:2:1:15",
"relatedInformation": [],
"severity": 2,
"source": "typst"
}
]
}

View file

@ -477,12 +477,16 @@ impl Redact for RedactFields {
}
pub(crate) fn file_path(uri: &str) -> String {
file_path_(&lsp_types::Url::parse(uri).unwrap())
}
pub(crate) fn file_path_(uri: &lsp_types::Url) -> String {
let root = if cfg!(windows) {
PathBuf::from("C:\\root")
} else {
PathBuf::from("/root")
};
let uri = lsp_types::Url::parse(uri).unwrap().to_file_path().unwrap();
let uri = uri.to_file_path().unwrap();
let abs_path = Path::new(&uri).strip_prefix(root).map(|p| p.to_owned());
let rel_path =
abs_path.unwrap_or_else(|_| Path::new("-").join(Path::new(&uri).iter().last().unwrap()));