From 89bd9fc7995d3523fcc0af36f2a5930d2f48ce08 Mon Sep 17 00:00:00 2001 From: ajuvercr Date: Wed, 14 Oct 2026 11:26:38 +0200 Subject: [PATCH 1/6] feat(turtle): rewrite formatter as two-pass doc builder on `pretty` Layout decisions are now made while building a `pretty` document, and rendering is left to pretty's group/fits algorithm. Hard lines propagate breaks to enclosing groups, so nested multi-line blank nodes and collections lay out correctly without rewinding the output buffer. - single-PO blank nodes stay inline when they fit, else break after `[` - multi-PO blank nodes always break after `[` (Style knob) - collections stay inline when they fit, else one item per line - object lists of simple terms break all-or-nothing; lists containing nested structures keep `], [` together Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 30 +- lang-turtle/Cargo.toml | 1 + lang-turtle/src/lang/formatter.rs | 580 ++++++++++++++++-------------- 3 files changed, 330 insertions(+), 281 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 38c1b5f8b2..a251f7bbe4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -103,6 +103,12 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + [[package]] name = "arrayvec" version = "0.7.6" @@ -285,7 +291,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9cf7a3ee41342dd7b5a5d82e200d0e8efb933169247fce853b4ad633d51e87d" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "bevy_ecs_macros", "bevy_platform", "bevy_ptr", @@ -2198,6 +2204,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "pretty" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" +dependencies = [ + "arrayvec 0.5.2", + "typed-arena", + "unicode-width", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -2682,7 +2699,7 @@ version = "1.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "borsh", "bytes", "num-traits", @@ -3451,6 +3468,7 @@ version = "0.2.1" dependencies = [ "bevy_ecs", "futures", + "pretty", "rdf-parsers", "rowan", "swls-core", @@ -4000,6 +4018,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "typeid" version = "1.0.3" @@ -4131,7 +4155,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" dependencies = [ - "arrayvec", + "arrayvec 0.7.6", "memchr", ] diff --git a/lang-turtle/Cargo.toml b/lang-turtle/Cargo.toml index c6569f8af2..b382321989 100644 --- a/lang-turtle/Cargo.toml +++ b/lang-turtle/Cargo.toml @@ -21,6 +21,7 @@ swls-lang-rdf-base.workspace = true swls-core = { workspace = true } rdf-parsers.workspace = true rowan = "0.16.1" +pretty = "0.12.5" [dev-dependencies] test-log = { version = "0.2.19", features = ["trace"] } diff --git a/lang-turtle/src/lang/formatter.rs b/lang-turtle/src/lang/formatter.rs index 5fb99f23a0..9388099be3 100644 --- a/lang-turtle/src/lang/formatter.rs +++ b/lang-turtle/src/lang/formatter.rs @@ -1,338 +1,311 @@ -use std::{ - io::{self, Cursor, Write}, - ops::Range, -}; - +//! Turtle formatter. +//! +//! Formatting happens in two passes: +//! 1. The AST is converted into a [`pretty`] document. All style decisions +//! (what must break, what may break, where comments go) are made here. +//! 2. `pretty` renders the document, choosing per group whether it fits on +//! the current line. A hard line inside a group always breaks that group, +//! so "a child is multi-line" propagates upwards without backtracking. + +use std::ops::Range; + +use pretty::RcDoc; use swls_core::text::LineIndex; -use swls_core::{ - lsp_types::FormattingOptions, - prelude::{spanned, Spanned}, -}; -use tracing::{trace, warn}; +use swls_core::{lsp_types::FormattingOptions, prelude::Spanned}; +use tracing::warn; use swls_lang_rdf_base::traits::{Base, BlankNode, Term, Triple, Turtle, TurtlePrefix, PO}; -type Buf = Cursor>; +type Doc = RcDoc<'static, ()>; + #[derive(Default, Clone, Copy)] pub enum PrefixStyle { #[default] OldSchool, Sparql, } -struct FormatState<'a> { - indent_level: usize, - indent: String, - buf: Buf, - line_start: u64, - comments: &'a [Spanned], - comments_idx: usize, - tail: Spanned, - line_count: usize, - prefix_style: PrefixStyle, -} - -impl<'a> FormatState<'a> { - fn new( - options: FormattingOptions, - buf: Buf, - comments: &'a [Spanned], - source: &'a LineIndex, - prefix_style: PrefixStyle, - ) -> Self { - let mut indent = String::new(); - for _ in 0..options.tab_size { - indent.push(' '); - } - // Sentinel span past the end of the document, so the trailing comment - // bucket sorts after every real span. - let tail = spanned( - String::new(), - source.len_bytes() + 1..source.len_bytes() + 1, - ); +/// Knobs that influence how the document is laid out. +#[derive(Clone, Copy)] +struct Style { + indent: isize, + max_width: usize, + /// Blank nodes with more than one predicate-object pair are always + /// written over multiple lines, even when they would fit. + break_multi_po_bnodes: bool, +} +impl Style { + fn new(options: &FormattingOptions) -> Self { Self { - tail, - line_start: 0, - indent_level: 0, - indent, - buf, - comments, - comments_idx: 0, - line_count: 0, - prefix_style, + indent: options.tab_size as isize, + max_width: 80, + break_multi_po_bnodes: true, } } +} - fn check_comments(&mut self, span: &Range) -> io::Result { - trace!("checking comments with span {:?}", span); - let mut first = true; - loop { - let current = self.comments.get(self.comments_idx).unwrap_or(&self.tail); +struct FormatError(&'static str); + +type Result = std::result::Result; + +struct DocBuilder<'a> { + style: Style, + comments: &'a [Spanned], + comments_idx: usize, +} - if current.1.start > span.start { +impl<'a> DocBuilder<'a> { + /// Takes all not yet emitted comments that start before `pos`. + fn take_comments(&mut self, pos: usize) -> Vec<&'a str> { + let mut out = Vec::new(); + while let Some(c) = self.comments.get(self.comments_idx) { + if c.1.start > pos { break; } - - first = false; - write!(self.buf, "{}", current.0)?; - self.new_line()?; + out.push(c.0.as_str()); self.comments_idx += 1; } - Ok(!first) - } - fn current_line_length(&self) -> u64 { - self.buf.position() - self.line_start - } - fn new_line(&mut self) -> io::Result<()> { - self.line_count += 1; - write!(self.buf, "\n")?; - self.line_start = self.buf.position(); - for _ in 0..self.indent_level { - write!(self.buf, "{}", &self.indent)?; - } - Ok(()) + out } - fn inc(&mut self) { - self.indent_level += 1; - } - fn decr(&mut self) { - self.indent_level -= 1; - } -} -impl FormatState<'_> { - fn write_turtle(&mut self, turtle: &Turtle) -> io::Result<()> { - if let Some(ref b) = turtle.base { - self.check_comments(&b.1)?; - self.write_base(b)?; - self.new_line()?; - } - for p in &turtle.prefixes { - self.check_comments(&p.1)?; - self.write_prefix(p)?; - self.new_line()?; - } + /// Comments before `pos`, each followed by a hard line. + fn comments_doc(&mut self, pos: usize) -> Doc { + Doc::concat( + self.take_comments(pos) + .into_iter() + .map(|c| Doc::text(c.to_string()).append(Doc::hardline())), + ) + } - let mut prev_line = 0; + fn has_comment_in(&self, span: &Range) -> bool { + self.comments[self.comments_idx..] + .iter() + .any(|c| span.contains(&c.1.start)) + } - for t in &turtle.triples { - if prev_line + 1 < self.line_count { - self.new_line()?; + /// Whether this term is always written over multiple lines, + /// independent of where it ends up on the line. + fn forces_break(&self, term: &Term, span: &Range) -> bool { + match term { + Term::BlankNode(BlankNode::Unnamed(pos, _, _)) => self.forces_break_bnode(pos, span), + Term::Collection(items) => { + self.has_comment_in(span) || items.iter().any(|t| self.forces_break(&t.0, &t.1)) } - prev_line = self.line_count; - self.check_comments(&t.1)?; - self.write_triple(&t)?; - self.new_line()?; - // request_newline = t.0.po.len() > 1 || t.0.po[0].0.object.len() > 1; - } - self.new_line()?; - - for i in self.comments_idx..self.comments.len() { - write!(self.buf, "{}", self.comments[i].0)?; - self.new_line()?; + _ => false, } + } - Ok(()) + fn forces_break_bnode(&self, pos: &[Spanned], span: &Range) -> bool { + (self.style.break_multi_po_bnodes && pos.len() > 1) + || self.has_comment_in(span) + || pos + .iter() + .flat_map(|po| po.object.iter()) + .any(|o| self.forces_break(&o.0, &o.1)) } - fn write_prefix(&mut self, prefix: &TurtlePrefix) -> io::Result<()> { - match self.prefix_style { - PrefixStyle::OldSchool => { - write!(self.buf, "@prefix {}: {}.", prefix.prefix.0, prefix.value.0) - } - PrefixStyle::Sparql => { - write!(self.buf, "PREFIX {}: {}", prefix.prefix.0, prefix.value.0) - } + fn prefix(&self, prefix: &TurtlePrefix, style: PrefixStyle) -> String { + match style { + PrefixStyle::OldSchool => format!("@prefix {}: {}.", prefix.prefix.0, prefix.value.0), + PrefixStyle::Sparql => format!("PREFIX {}: {}", prefix.prefix.0, prefix.value.0), } } - fn write_base(&mut self, base: &Base) -> io::Result<()> { - match self.prefix_style { - PrefixStyle::OldSchool => { - write!(self.buf, "@base {}.", base.1 .0) - } - PrefixStyle::Sparql => { - write!(self.buf, "BASE {}", base.1 .0) - } + fn base(&self, base: &Base, style: PrefixStyle) -> String { + match style { + PrefixStyle::OldSchool => format!("@base {}.", base.1 .0), + PrefixStyle::Sparql => format!("BASE {}", base.1 .0), } } - fn write_bnode(&mut self, bnode: &BlankNode) -> io::Result<()> { - match bnode { - BlankNode::Named(x, _) => write!(self.buf, "_:{}", x)?, - BlankNode::Unnamed(pos, _, _) => { - if pos.len() == 0 { - return write!(self.buf, "[ ]"); - } - if pos.len() == 1 { - write!(self.buf, "[ ")?; - self.write_po(&pos[0])?; - return write!(self.buf, " ]"); - } - let is_first_of_line = self.current_line_length() == 0; - self.inc(); - write!(self.buf, "[")?; - let should_skip = if is_first_of_line { - write!(self.buf, " ")?; - self.write_po(&pos[0])?; - write!(self.buf, ";")?; - 1 - } else { - 0 - }; - for po in pos.iter().skip(should_skip) { - self.new_line()?; - self.check_comments(&po.1)?; - self.write_po(&po)?; - write!(self.buf, ";")?; - } - self.decr(); - self.new_line()?; - write!(self.buf, "]")?; - } - BlankNode::Invalid => return Err(io::Error::new(io::ErrorKind::Other, "")), + fn term(&mut self, term: &Spanned) -> Result { + match &term.0 { + Term::Literal(s) => Ok(Doc::text(s.to_string())), + Term::NamedNode(n) => Ok(Doc::text(n.to_string())), + Term::BlankNode(b) => self.bnode(b, &term.1), + Term::Collection(items) => self.collection(items), + Term::Invalid => Err(FormatError("cannot format turtle with invalid terms")), + Term::Variable(_) => Err(FormatError("cannot format turtle with variables")), } - Ok(()) } - fn write_collection(&mut self, coll: &Vec>) -> io::Result<()> { - if coll.is_empty() { - return write!(self.buf, "( )"); + fn bnode(&mut self, bnode: &BlankNode, span: &Range) -> Result { + let pos = match bnode { + BlankNode::Named(x, _) => return Ok(Doc::text(format!("_:{}", x))), + BlankNode::Unnamed(pos, _, _) => pos, + BlankNode::Invalid => return Err(FormatError("invalid blank node")), + }; + if pos.is_empty() { + return Ok(Doc::text("[ ]")); } - let mut should_indent = false; - let start = self.buf.position(); - let current_line = self.line_count; + let forced = self.forces_break_bnode(pos, span); + if pos.len() == 1 && !forced { + // `[ p o ]` when it fits, otherwise break after `[`. + let body = self.comments_doc(pos[0].1.start).append(self.po(&pos[0])?); + return Ok(Doc::text("[") + .append(Doc::line().append(body).nest(self.style.indent)) + .append(Doc::line()) + .append(Doc::text("]")) + .group()); + } - write!(self.buf, "( ")?; + if pos.len() == 1 { + // Multi-line content: break after `[`, no trailing `;`. + let body = self.comments_doc(pos[0].1.start).append(self.po(&pos[0])?); + return Ok(Doc::text("[") + .append(Doc::hardline().append(body).nest(self.style.indent)) + .append(Doc::hardline()) + .append(Doc::text("]"))); + } - self.check_comments(&coll[0].1)?; - self.write_term(&coll[0])?; + let mut body = Doc::nil(); + for po in pos { + body = body + .append(Doc::hardline()) + .append(self.comments_doc(po.1.start)) + .append(self.po(po)?) + .append(Doc::text(";")); + } + Ok(Doc::text("[") + .append(body.nest(self.style.indent)) + .append(Doc::hardline()) + .append(Doc::text("]"))) + } - for po in coll.iter().skip(1) { - self.check_comments(&po.1)?; - write!(self.buf, " ")?; - self.write_term(&po)?; - if self.current_line_length() > 80 { - should_indent = true; - break; - } + fn collection(&mut self, items: &[Spanned]) -> Result { + if items.is_empty() { + return Ok(Doc::text("( )")); } - write!(self.buf, " )")?; - - if should_indent { - self.buf.set_position(start); - self.line_count = current_line; - write!(self.buf, "(")?; - self.inc(); - for po in coll.iter() { - self.new_line()?; - self.check_comments(&po.1)?; - self.write_term(&po)?; - } - self.decr(); - self.new_line()?; - write!(self.buf, ")")?; + // `( a b )` when it fits, otherwise one item per line. + let mut body = Doc::nil(); + for item in items { + body = body + .append(Doc::line()) + .append(self.comments_doc(item.1.start)) + .append(self.term(item)?); } - - Ok(()) + Ok(Doc::text("(") + .append(body.nest(self.style.indent)) + .append(Doc::line()) + .append(Doc::text(")")) + .group()) } - fn write_term(&mut self, term: &Term) -> io::Result<()> { + fn is_atomic(term: &Term) -> bool { match term { - Term::Literal(s) => write!(self.buf, "{}", s)?, - Term::BlankNode(b) => self.write_bnode(b)?, - Term::NamedNode(n) => write!(self.buf, "{}", n)?, - Term::Collection(ts) => self.write_collection(ts)?, - Term::Invalid => { - return Err(io::Error::new( - io::ErrorKind::Other, - "cannot format turtle with invalid terms", - )) - } - Term::Variable(_) => { - return Err(io::Error::new( - io::ErrorKind::Other, - "cannot format turtle with variables", - )) - } + Term::BlankNode(BlankNode::Unnamed(pos, _, _)) => pos.is_empty(), + Term::Collection(items) => items.is_empty(), + _ => true, } - Ok(()) } - fn write_po(&mut self, po: &PO) -> io::Result<()> { - write!(self.buf, "{} ", po.predicate.0)?; - self.write_term(&po.object[0])?; - let mut should_indent = false; - - let start = self.buf.position(); - let current_line = self.line_count; - for i in 1..po.object.len() { - write!(self.buf, ", ")?; - self.write_term(&po.object[i])?; - - if self.current_line_length() > 80 { - should_indent = true; - break; + fn po(&mut self, po: &PO) -> Result { + let mut doc = Doc::text(format!("{} ", po.predicate.0)); + let Some((first, rest)) = po.object.split_first() else { + return Err(FormatError("predicate without object")); + }; + doc = doc.append(self.term(first)?); + + if po.object.iter().all(|o| Self::is_atomic(&o.0)) { + // All on one line, or every object on its own (indented) line. + let mut tail = Doc::nil(); + for o in rest { + tail = tail + .append(Doc::text(",")) + .append(Doc::line()) + .append(self.comments_doc(o.1.start)) + .append(self.term(o)?); + } + Ok(doc.append(tail.nest(self.style.indent)).group()) + } else { + // Nested structures lay themselves out; keep `], [` together. + for o in rest { + doc = doc + .append(Doc::text(", ")) + .append(self.comments_doc(o.1.start)) + .append(self.term(o)?); } + Ok(doc) } + } - if should_indent { - self.buf.set_position(start); - self.line_count = current_line; - self.inc(); - for i in 1..po.object.len() { - write!(self.buf, ",")?; - self.new_line()?; - self.check_comments(&po.object[i].1)?; - self.write_term(&po.object[i])?; - } - self.decr(); + fn triple(&mut self, triple: &Triple) -> Result { + let mut doc = match &triple.subject.0 { + Term::BlankNode(bn) => self.bnode(bn, &triple.subject.1)?, + Term::NamedNode(n) => Doc::text(n.to_string()), + _ => Doc::text("invalid"), + }; + let Some((first, rest)) = triple.po.split_first() else { + return Ok(doc.append(Doc::text("."))); + }; + doc = doc.append(Doc::text(" ")).append(self.po(first)?); + + let mut tail = Doc::nil(); + for po in rest { + tail = tail + .append(Doc::text(";")) + .append(Doc::hardline()) + .append(self.comments_doc(po.1.start)) + .append(self.po(po)?); } + Ok(doc + .append(tail.nest(self.style.indent)) + .append(Doc::text("."))) + } - Ok(()) + fn render(&self, doc: Doc) -> String { + let mut out = String::new(); + // Writing to a String cannot fail. + let _ = doc.render_fmt(self.style.max_width, &mut out); + out } - fn write_triple(&mut self, triple: &Triple) -> io::Result<()> { - match &triple.subject.0 { - Term::BlankNode(bn) => self.write_bnode(bn)?, - Term::NamedNode(n) => write!(self.buf, "{}", n)?, - _ => write!(self.buf, "invalid")?, - } - if triple.po.is_empty() { - write!(self.buf, ".")?; - return Ok(()); + fn turtle(&mut self, turtle: &Turtle, prefix_style: PrefixStyle) -> Result { + let mut out = String::new(); + let mut line_count = 0; + let push_line = |out: &mut String, line_count: &mut usize, s: &str| { + out.push_str(s); + out.push('\n'); + *line_count += s.matches('\n').count() + 1; + }; + + if let Some(ref b) = turtle.base { + for c in self.take_comments(b.1.start) { + push_line(&mut out, &mut line_count, c); + } + push_line(&mut out, &mut line_count, &self.base(b, prefix_style)); } - write!(self.buf, " ")?; - self.write_po(&triple.po[0])?; - if triple.po.len() == 1 { - write!(self.buf, ".")?; - return Ok(()); + for p in &turtle.prefixes { + for c in self.take_comments(p.1.start) { + push_line(&mut out, &mut line_count, c); + } + push_line(&mut out, &mut line_count, &self.prefix(p, prefix_style)); } - write!(self.buf, ";")?; - self.inc(); - self.new_line()?; - self.check_comments(&triple.po[1].1)?; - self.write_po(&triple.po[1])?; - - if triple.po.len() == 2 { - self.decr(); - write!(self.buf, ".")?; - return Ok(()); + // Separate a statement from the previous one with a blank line + // when the previous chunk spanned multiple lines. + let mut prev_line = 0; + for t in &turtle.triples { + if prev_line + 1 < line_count { + push_line(&mut out, &mut line_count, ""); + } + prev_line = line_count; + for c in self.take_comments(t.1.start) { + push_line(&mut out, &mut line_count, c); + } + let doc = self.triple(t)?; + let rendered = self.render(doc); + push_line(&mut out, &mut line_count, &rendered); } + push_line(&mut out, &mut line_count, ""); - for i in 2..triple.po.len() { - write!(self.buf, ";")?; - self.new_line()?; - self.check_comments(&triple.po[i].1)?; - self.write_po(&triple.po[i])?; + for c in self.take_comments(usize::MAX) { + push_line(&mut out, &mut line_count, c); } - write!(self.buf, ".")?; - self.decr(); - Ok(()) + Ok(out) } } @@ -340,19 +313,24 @@ pub fn format_turtle( turtle: &Turtle, config: FormattingOptions, comments: &[Spanned], - source: &LineIndex, + _source: &LineIndex, prefix_style: impl Into, ) -> Option { - let buf: Buf = Cursor::new(Vec::new()); - let mut state = FormatState::new(config, buf, comments, source, prefix_style.into()); - match state.write_turtle(turtle) { - Ok(_) => tracing::debug!("Format successful"), + let mut builder = DocBuilder { + style: Style::new(&config), + comments, + comments_idx: 0, + }; + match builder.turtle(turtle, prefix_style.into()) { + Ok(out) => { + tracing::debug!("Format successful"); + Some(out) + } Err(e) => { - warn!("Format unsuccessful {:?}", e); - return None; + warn!("Format unsuccessful: {}", e.0); + None } } - String::from_utf8(state.buf.into_inner()).ok() } #[cfg(test)] @@ -471,7 +449,8 @@ mod tests { "#; - let expected = r#"[ foaf:Person; + let expected = r#"[ + foaf:Person; foaf:knows ; foaf:knows ; ] foaf:knows [ @@ -702,4 +681,49 @@ mod tests { .expect("formatting"); assert_eq!(formatted, expected); } + + #[test] + fn format_nested_bnodes() { + let txt = r#"[] sh:or ( [ sh:property [ + sh:path crm:P4_has_time-span; + sh:minCount 1; + ] ] [ sh:property [ + sh:path crm:P7_took_place_at; + sh:minCount 1; + ] ] ). + "#; + + let expected = r#"[ ] sh:or ( + [ + sh:property [ + sh:path crm:P4_has_time-span; + sh:minCount 1; + ] + ] + [ + sh:property [ + sh:path crm:P7_took_place_at; + sh:minCount 1; + ] + ] +). + +"#; + + let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); + let (output, comments) = parse_turtle(txt, &url); + let formatted = format_turtle( + &output, + swls_core::lsp_types::FormattingOptions { + tab_size: 2, + ..Default::default() + }, + &comments, + &LineIndex::new(txt), + PrefixStyle::OldSchool, + ) + .expect("formatting"); + + assert_eq!(formatted, expected); + } } From 49e0f6671ab739cc8ef2236e65f48c1a754b4fe3 Mon Sep 17 00:00:00 2001 From: ajuvercr Date: Wed, 14 Oct 2026 14:05:31 +0200 Subject: [PATCH 2/6] feat: use the rdf-parsers formatters for Turtle, TriG and SPARQL Formatting: - Point rdf-parsers at the local checkout (../rdf-parsers), which has the pretty-based generated formatters. - Turtle formats from the stored syntax tree with rdf-parsers' TurtleHook; a small hook on top keeps rewriting prefix/base declarations to the configured `prefix_format`. The hand-written formatter and its `pretty` dependency are gone. - TriG picks up the new formatter through the dependency. - SPARQL gets a format system: the parse stores the syntax tree on a clean parse, and formatting is opt-in via `format.sparql`. Config: - `format` moves into LocalConfig, so it also works from ~/.config/swls/config.json and .swls/config.json; later sources override the toggles they set. - Invalid initializationOptions are reported (log + client output) instead of silently resetting every setting. - README documents `format`, `prefix_format` and source precedence. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 +- Cargo.toml | 4 +- README.md | 18 +- core/src/backend.rs | 19 +- core/src/components/config.rs | 78 ++- lang-jsonld/src/ecs/mod.rs | 2 +- lang-n3/src/ecs/mod.rs | 2 +- lang-sparql/src/ecs/mod.rs | 93 +++- lang-sparql/src/lib.rs | 3 +- lang-trig/src/ecs/mod.rs | 5 +- lang-turtle/Cargo.toml | 1 - lang-turtle/src/ecs/format.rs | 20 +- lang-turtle/src/lang/formatter.rs | 765 +++--------------------------- 13 files changed, 282 insertions(+), 732 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a251f7bbe4..d3a8370945 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2472,12 +2472,11 @@ dependencies = [ [[package]] name = "rdf-parsers" version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d1eee344235c9e9d033caed31059cf226f24e81e0621297c57506a0b513a5a8" dependencies = [ "async-trait", "logos", "oxiri", + "pretty", "rowan", "similar", ] @@ -3468,7 +3467,6 @@ version = "0.2.1" dependencies = [ "bevy_ecs", "futures", - "pretty", "rdf-parsers", "rowan", "swls-core", diff --git a/Cargo.toml b/Cargo.toml index dff5aa6ea1..e938dacb2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,8 @@ bevy_ecs = { version = "0.18", default-features = true, features = [ derive_more = { version = "2", features = ["as_ref", "deref", "deref_mut", "is_variant"] } futures = { version = "0.3.31", features = ["alloc", "executor"], default-features = false } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } -# rdf-parsers = { path = "../rdf-parsers/" } -rdf-parsers = { version = "0.1.16" } +rdf-parsers = { path = "../rdf-parsers/" } +# rdf-parsers = { version = "0.1.16" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.145" sophia_api = "0.9.0" diff --git a/README.md b/README.md index 29e0bf4a8b..1baafb5871 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Details and caveats for each editor are in [Installation](#installation) below. | **Hover** | Inferred RDF type · class & property documentation from the ontology · explanation when a property is only accepted via your allow-list | | **Navigation** | Go to definition (RDF terms and, for JSON-LD, Components.js modules/parameters) · go to type definition · find references · rename | | **Code actions** | Add missing prefix declaration · allow-list an unknown property · organize/sort `@prefix` imports (Turtle) · extract/inline a blank node | -| **Formatting** | Document formatting for Turtle and JSON-LD · auto-insert the prefix declaration while typing | +| **Formatting** | Document formatting for Turtle, TriG, SPARQL, N3 and JSON-LD · auto-insert the prefix declaration while typing | | **Highlighting** | Semantic syntax highlighting | | **Inlay hints** | Inferred type shown inline next to subjects missing an explicit `rdf:type` | @@ -68,19 +68,31 @@ point your editor's LSP client at it for `.ttl`, `.trig`, `.jsonld` and `.sparql ## Configuration SWLS reads configuration from the client's `initializationOptions`, plus optional -`.swls/config.json` (workspace) and `~/.config/swls/config.json` (global) files. +`~/.config/swls/config.json` (global) and `.swls/config.json` (workspace) files. Later sources +win: a workspace file overrides the global one, which overrides `initializationOptions`. ```json { "turtle": true, "sparql": false, - "disabled": ["unused_prefix", "hover_excluded_property"] + "disabled": ["unused_prefix", "hover_excluded_property"], + "format": { "trig": true, "sparql": true }, + "prefix_format": "sparql" } ``` - `turtle` / `trig` / `jsonld` / `sparql` (default `true`) — enable/disable a language plugin entirely. + These are only read from `initializationOptions`. - `disabled` — a list of individual diagnostics or LSP (sub-)features to turn off, e.g. just the "unused prefix" warning, or just hover-on-class without touching the rest of hover. +- `format` — turn document formatting on or off per language: `turtle` (default `true`), `trig`, + `sparql`, `n3`, `jsonld` (default `false`). +- `prefix_format` — `"turtle"` (default) writes `@prefix ex: <…>.`, `"sparql"` writes + `PREFIX ex: <…>`. Used when inserting prefixes, and the Turtle formatter rewrites existing + declarations to match. + +If `initializationOptions` contains an invalid value, SWLS logs a warning to the editor's output +and falls back to the defaults. ## Documentation diff --git a/core/src/backend.rs b/core/src/backend.rs index 9de623f7fe..2bbe71530c 100644 --- a/core/src/backend.rs +++ b/core/src/backend.rs @@ -13,7 +13,7 @@ use goto_type::GotoTypeRequest; use references::ReferencesRequest; use request::{GotoTypeDefinitionParams, GotoTypeDefinitionResponse}; use crate::text::LineIndex; -use tracing::{debug, error, info, instrument}; +use tracing::{debug, error, info, instrument, warn}; use crate::{ client::Client, @@ -143,9 +143,20 @@ impl Backend { info!("Initialize"); let workspaces = init.workspace_folders.clone().unwrap_or_default(); - let config: Config = - serde_json::from_value(init.initialization_options.clone().unwrap_or_default()) - .unwrap_or_default(); + let config: Config = match init.initialization_options.clone() { + None | Some(serde_json::Value::Null) => Config::default(), + Some(options) => match serde_json::from_value(options) { + Ok(config) => config, + Err(e) => { + // A single wrong value would otherwise reset every setting + // without a trace. + let msg = format!("Invalid swls initializationOptions, using defaults: {e}"); + warn!("{msg}"); + self.client.log_message(MessageType::WARNING, msg).await; + Config::default() + } + }, + }; let mut server_config = ServerConfig { config, workspaces }; diff --git a/core/src/components/config.rs b/core/src/components/config.rs index cda74bbe29..6ff9226a1b 100644 --- a/core/src/components/config.rs +++ b/core/src/components/config.rs @@ -163,10 +163,6 @@ pub struct Config { pub jsonld: Option, /// Enable sparql pub sparql: Option, - /// Per-language `textDocument/formatting` toggles. Formatting is disabled by - /// default for every language except Turtle. - #[serde(default)] - pub format: FormatConfig, /// Extra local configuration #[serde(flatten)] pub local: LocalConfig, @@ -183,16 +179,40 @@ pub struct Config { #[serde(default)] pub struct FormatConfig { /// Enable Turtle formatting (default: `true`). + #[serde(skip_serializing_if = "Option::is_none")] pub turtle: Option, /// Enable TriG formatting (default: `false`). + #[serde(skip_serializing_if = "Option::is_none")] pub trig: Option, /// Enable N3 formatting (default: `false`). + #[serde(skip_serializing_if = "Option::is_none")] pub n3: Option, /// Enable JSON-LD formatting (default: `false`). + #[serde(skip_serializing_if = "Option::is_none")] pub jsonld: Option, + /// Enable SPARQL formatting (default: `false`). + #[serde(skip_serializing_if = "Option::is_none")] + pub sparql: Option, } impl FormatConfig { + /// Combines this config with another, giving precedence to the other's + /// explicitly set toggles. + pub fn combine(&mut self, other: FormatConfig) { + let FormatConfig { + turtle, + trig, + n3, + jsonld, + sparql, + } = other; + self.turtle = turtle.or(self.turtle); + self.trig = trig.or(self.trig); + self.n3 = n3.or(self.n3); + self.jsonld = jsonld.or(self.jsonld); + self.sparql = sparql.or(self.sparql); + } + /// Whether Turtle formatting is enabled (defaults to `true`). pub fn turtle(&self) -> bool { self.turtle.unwrap_or(true) @@ -209,6 +229,10 @@ impl FormatConfig { pub fn jsonld(&self) -> bool { self.jsonld.unwrap_or(false) } + /// Whether SPARQL formatting is enabled (defaults to `false`). + pub fn sparql(&self) -> bool { + self.sparql.unwrap_or(false) + } } #[derive(Debug, Deserialize, Serialize, Default)] @@ -226,6 +250,9 @@ pub struct LocalConfig { pub completion: CompletionConfig, /// Preferred way to write Turtle/TriG prefix declarations when inserting them. pub prefix_format: Option, + /// Per-language `textDocument/formatting` toggles. Formatting is disabled by + /// default for every language except Turtle. + pub format: FormatConfig, /// Namespaces for which IRIs used as properties (predicates) must be defined in a /// known ontology. Predicate IRIs that start with one of these namespaces but are /// not a known property (and not in [`allowed_properties`]) are flagged with a warning. @@ -351,6 +378,7 @@ impl LocalConfig { if other.prefix_format.is_some() { self.prefix_format = other.prefix_format; } + self.format.combine(other.format); self.closed_namespaces.extend(other.closed_namespaces); self.allowed_properties.extend(other.allowed_properties); } @@ -430,7 +458,6 @@ impl Default for Config { n3: None, jsonld: None, sparql: None, - format: FormatConfig::default(), local: LocalConfig::default(), } } @@ -439,3 +466,44 @@ impl Default for Config { fn debug() -> String { String::from("debug") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_from_config_file() { + let local: LocalConfig = + serde_json::from_str(r#"{ "format": { "sparql": true } }"#).unwrap(); + assert!(local.format.sparql()); + assert!(local.format.turtle(), "unset toggles keep their defaults"); + assert!(!local.format.trig()); + } + + #[test] + fn format_from_initialization_options() { + let config: Config = + serde_json::from_value(serde_json::json!({ "format": { "trig": true } })).unwrap(); + assert!(config.local.format.trig()); + } + + #[test] + fn later_sources_override_set_toggles_only() { + let mut config: LocalConfig = + serde_json::from_str(r#"{ "format": { "sparql": true, "turtle": false } }"#).unwrap(); + let workspace: LocalConfig = + serde_json::from_str(r#"{ "format": { "sparql": false } }"#).unwrap(); + config.combine(workspace); + assert!(!config.format.sparql()); + assert!( + !config.format.turtle(), + "toggles the workspace leaves unset are kept" + ); + } + + #[test] + fn saving_config_writes_no_unset_toggles() { + let json = serde_json::to_value(LocalConfig::default()).unwrap(); + assert_eq!(json["format"], serde_json::json!({})); + } +} diff --git a/lang-jsonld/src/ecs/mod.rs b/lang-jsonld/src/ecs/mod.rs index 5852299304..f79685e0c8 100644 --- a/lang-jsonld/src/ecs/mod.rs +++ b/lang-jsonld/src/ecs/mod.rs @@ -540,7 +540,7 @@ pub(crate) fn format_jsonld_system( config: Res, ) { use swls_core::lsp_types::{Position, Range}; - if !config.config.format.jsonld() { + if !config.config.local.format.jsonld() { tracing::debug!("JSON-LD formatting disabled by config"); return; } diff --git a/lang-n3/src/ecs/mod.rs b/lang-n3/src/ecs/mod.rs index c4fdf403cd..8e62d2865b 100644 --- a/lang-n3/src/ecs/mod.rs +++ b/lang-n3/src/ecs/mod.rs @@ -237,7 +237,7 @@ pub(crate) fn format_n3_system( config: Res, ) { use swls_core::lsp_types::{Position, Range}; - if !config.config.format.n3() { + if !config.config.local.format.n3() { tracing::debug!("N3 formatting disabled by config"); return; } diff --git a/lang-sparql/src/ecs/mod.rs b/lang-sparql/src/ecs/mod.rs index a318b9549a..ee46b64d47 100644 --- a/lang-sparql/src/ecs/mod.rs +++ b/lang-sparql/src/ecs/mod.rs @@ -33,12 +33,48 @@ pub fn setup_parse(world: &mut World) { }); } +pub fn setup_format(world: &mut World) { + world.schedule_scope(FormatLabel, |_, schedule| { + schedule.add_systems(format_sparql_system); + }); +} + +fn format_sparql_system( + mut query: Query< + (&RopeC, &Wrapped, &mut FormatRequest), + (With, Without), + >, + config: Res, +) { + use swls_core::lsp_types::{Position, Range, TextEdit}; + if !config.config.local.format.sparql() { + tracing::debug!("SPARQL formatting disabled by config"); + return; + } + for (source, node, mut request) in &mut query { + if request.0.is_some() { + tracing::debug!("Didn't format with the sparql format system, already formatted"); + continue; + } + tracing::debug!("Formatting with sparql format system"); + + let root = rowan::SyntaxNode::new_root(node.0.clone()); + let formatted = rdf_parsers::sparql::format::format(&root, 80); + + request.0 = Some(vec![TextEdit::new( + Range::new( + Position::new(0, 0), + Position::new(source.0.len_lines() as u32 + 1, 0), + ), + formatted, + )]); + } +} + pub fn setup_completion(world: &mut World) { use swls_core::feature::completion::*; world.schedule_scope(Label, |_, schedule| { - schedule.add_systems(( - variable_completion.after(generate_completions), - )); + schedule.add_systems((variable_completion.after(generate_completions),)); }); } @@ -103,6 +139,7 @@ fn parse_sparql_system( commands .entity(entity) .insert((element, Errors(errors), CstTokens(cst_tokens))) + .insert(Wrapped(syntax.green().into_owned())) .remove::(); } else { commands @@ -218,3 +255,53 @@ pub fn variable_completion( } } } + +#[cfg(test)] +mod format_tests { + use swls_core::prelude::*; + use swls_test_utils::{create_file, setup_world, TestClient}; + use test_log::test; + + fn format_with(sparql_enabled: Option) -> Option { + let (mut world, _rx) = setup_world(TestClient::new(), crate::setup_world); + + let mut cfg = ServerConfig::default(); + cfg.config.local.format.sparql = sparql_enabled; + world.insert_resource(cfg); + + let entity = create_file( + &mut world, + "SELECT ?s WHERE { ?s ?o . FILTER(?o>5) }", + "http://example.com/query.rq", + "sparql", + Open, + ); + // ParseLabel populates the green node the formatter consumes. + world.run_schedule(ParseLabel); + + world.entity_mut(entity).insert(FormatRequest(None)); + world.run_schedule(FormatLabel); + + world + .entity_mut(entity) + .take::() + .and_then(|r| r.0) + .map(|edits| edits[0].new_text.clone()) + } + + #[test] + fn sparql_formatting_off_by_default() { + assert!( + format_with(None).is_none(), + "SPARQL formatting should be disabled unless explicitly enabled" + ); + } + + #[test] + fn sparql_formatting_can_be_enabled() { + assert_eq!( + format_with(Some(true)).as_deref(), + Some("SELECT ?s\nWHERE {\n ?s ?o.\n FILTER (?o > 5)\n}\n") + ); + } +} diff --git a/lang-sparql/src/lib.rs b/lang-sparql/src/lib.rs index 6ab9785f61..8fb98e277d 100644 --- a/lang-sparql/src/lib.rs +++ b/lang-sparql/src/lib.rs @@ -7,7 +7,7 @@ use swls_lang_rdf_base::register_rdf_lang; use swls_lang_turtle::lang::parser::TurtleParseError; pub mod ecs; -use crate::ecs::{setup_completion, setup_parse}; +use crate::ecs::{setup_completion, setup_format, setup_parse}; pub mod lang; pub fn setup_world(world: &mut World) { @@ -20,6 +20,7 @@ pub fn setup_world(world: &mut World) { ); setup_parse(world); setup_completion(world); + setup_format(world); } #[derive(Debug, Component, Default)] diff --git a/lang-trig/src/ecs/mod.rs b/lang-trig/src/ecs/mod.rs index 5a101a0434..07d97fa391 100644 --- a/lang-trig/src/ecs/mod.rs +++ b/lang-trig/src/ecs/mod.rs @@ -24,7 +24,6 @@ pub fn setup_parsing(world: &mut World) { }); } - fn extract_trig_cst_tokens( node: &rowan::SyntaxNode, ) -> Vec> { @@ -147,7 +146,7 @@ pub(crate) fn format_trig_system( config: Res, ) { use swls_core::lsp_types::{Position, Range}; - if !config.config.format.trig() { + if !config.config.local.format.trig() { tracing::debug!("TriG formatting disabled by config"); return; } @@ -182,7 +181,7 @@ mod tests { let (mut world, _rx) = setup_world(TestClient::new(), crate::setup_world::); let mut cfg = ServerConfig::default(); - cfg.config.format.trig = trig_enabled; + cfg.config.local.format.trig = trig_enabled; world.insert_resource(cfg); let entity = create_file( diff --git a/lang-turtle/Cargo.toml b/lang-turtle/Cargo.toml index b382321989..c6569f8af2 100644 --- a/lang-turtle/Cargo.toml +++ b/lang-turtle/Cargo.toml @@ -21,7 +21,6 @@ swls-lang-rdf-base.workspace = true swls-core = { workspace = true } rdf-parsers.workspace = true rowan = "0.16.1" -pretty = "0.12.5" [dev-dependencies] test-log = { version = "0.2.19", features = ["trace"] } diff --git a/lang-turtle/src/ecs/format.rs b/lang-turtle/src/ecs/format.rs index 283784b043..06d514ebca 100644 --- a/lang-turtle/src/ecs/format.rs +++ b/lang-turtle/src/ecs/format.rs @@ -13,19 +13,19 @@ use crate::{ pub fn format_turtle_system( mut query: Query< - (&RopeC, &Element, &Comments, &mut FormatRequest), + (&RopeC, &Wrapped, &mut FormatRequest), (With, Without), >, config: Res, ) { debug!("Format turtle system"); - if !config.config.format.turtle() { + if !config.config.local.format.turtle() { debug!("Turtle formatting disabled by config"); return; } - for (source, turtle, comments, mut request) in &mut query { + for (source, node, mut request) in &mut query { if request.0.is_some() { debug!("Didn't format with the turtle format system, already formatted"); continue; @@ -43,16 +43,8 @@ pub fn format_turtle_system( }) .unwrap_or_default(); - let formatted = format_turtle( - &turtle.0, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments.0, - &source.0, - style, - ); + let root = rowan::SyntaxNode::new_root(node.0.clone()); + let formatted = Some(format_turtle(&root, style)); request.0 = formatted.map(|x| { vec![swls_core::lsp_types::TextEdit::new( @@ -92,6 +84,6 @@ mod test { assert!(m_formatted.is_some()); let formatted = &m_formatted.unwrap()[0].new_text; - assert_eq!(formatted, "@prefix foaf: <>.\n\n"); + assert_eq!(formatted, "@prefix foaf: <>.\n"); } } diff --git a/lang-turtle/src/lang/formatter.rs b/lang-turtle/src/lang/formatter.rs index 9388099be3..b99c29a5f1 100644 --- a/lang-turtle/src/lang/formatter.rs +++ b/lang-turtle/src/lang/formatter.rs @@ -1,22 +1,16 @@ -//! Turtle formatter. +//! Turtle formatting. //! -//! Formatting happens in two passes: -//! 1. The AST is converted into a [`pretty`] document. All style decisions -//! (what must break, what may break, where comments go) are made here. -//! 2. `pretty` renders the document, choosing per group whether it fits on -//! the current line. A hard line inside a group always breaks that group, -//! so "a child is multi-line" propagates upwards without backtracking. - -use std::ops::Range; - -use pretty::RcDoc; -use swls_core::text::LineIndex; -use swls_core::{lsp_types::FormattingOptions, prelude::Spanned}; -use tracing::warn; - -use swls_lang_rdf_base::traits::{Base, BlankNode, Term, Triple, Turtle, TurtlePrefix, PO}; - -type Doc = RcDoc<'static, ()>; +//! Layout comes from the generated formatter in `rdf_parsers` (grammar hints +//! plus `rdf_parsers::turtle::format::TurtleHook`). The only rule added here +//! is rewriting prefix and base declarations to the configured style. + +use rdf_parsers::{ + format::{Doc, FormatHook, Formatter}, + turtle::{ + format::TurtleHook, + parser::{format::format_with, Lang, SyntaxKind, SyntaxNode}, + }, +}; #[derive(Default, Clone, Copy)] pub enum PrefixStyle { @@ -25,705 +19,94 @@ pub enum PrefixStyle { Sparql, } -/// Knobs that influence how the document is laid out. -#[derive(Clone, Copy)] -struct Style { - indent: isize, - max_width: usize, - /// Blank nodes with more than one predicate-object pair are always - /// written over multiple lines, even when they would fit. - break_multi_po_bnodes: bool, +/// Format a Turtle document from its syntax tree. +pub fn format_turtle(root: &SyntaxNode, prefix_style: impl Into) -> String { + let hook = PrefixStyleHook { + style: prefix_style.into(), + }; + format_with(root, 80, &hook) } -impl Style { - fn new(options: &FormattingOptions) -> Self { - Self { - indent: options.tab_size as isize, - max_width: 80, - break_multi_po_bnodes: true, - } - } +/// [`TurtleHook`] that also writes prefix and base declarations in one style. +struct PrefixStyleHook { + style: PrefixStyle, } -struct FormatError(&'static str); - -type Result = std::result::Result; - -struct DocBuilder<'a> { - style: Style, - comments: &'a [Spanned], - comments_idx: usize, -} - -impl<'a> DocBuilder<'a> { - /// Takes all not yet emitted comments that start before `pos`. - fn take_comments(&mut self, pos: usize) -> Vec<&'a str> { - let mut out = Vec::new(); - while let Some(c) = self.comments.get(self.comments_idx) { - if c.1.start > pos { - break; - } - out.push(c.0.as_str()); - self.comments_idx += 1; - } - out - } - - /// Comments before `pos`, each followed by a hard line. - fn comments_doc(&mut self, pos: usize) -> Doc { - Doc::concat( - self.take_comments(pos) - .into_iter() - .map(|c| Doc::text(c.to_string()).append(Doc::hardline())), - ) - } - - fn has_comment_in(&self, span: &Range) -> bool { - self.comments[self.comments_idx..] - .iter() - .any(|c| span.contains(&c.1.start)) - } - - /// Whether this term is always written over multiple lines, - /// independent of where it ends up on the line. - fn forces_break(&self, term: &Term, span: &Range) -> bool { - match term { - Term::BlankNode(BlankNode::Unnamed(pos, _, _)) => self.forces_break_bnode(pos, span), - Term::Collection(items) => { - self.has_comment_in(span) || items.iter().any(|t| self.forces_break(&t.0, &t.1)) - } - _ => false, - } - } - - fn forces_break_bnode(&self, pos: &[Spanned], span: &Range) -> bool { - (self.style.break_multi_po_bnodes && pos.len() > 1) - || self.has_comment_in(span) - || pos - .iter() - .flat_map(|po| po.object.iter()) - .any(|o| self.forces_break(&o.0, &o.1)) - } - - fn prefix(&self, prefix: &TurtlePrefix, style: PrefixStyle) -> String { - match style { - PrefixStyle::OldSchool => format!("@prefix {}: {}.", prefix.prefix.0, prefix.value.0), - PrefixStyle::Sparql => format!("PREFIX {}: {}", prefix.prefix.0, prefix.value.0), - } +impl FormatHook for PrefixStyleHook { + fn keep(&self, element: &rowan::SyntaxElement) -> bool { + TurtleHook.keep(element) } - fn base(&self, base: &Base, style: PrefixStyle) -> String { - match style { - PrefixStyle::OldSchool => format!("@base {}.", base.1 .0), - PrefixStyle::Sparql => format!("BASE {}", base.1 .0), - } - } - - fn term(&mut self, term: &Spanned) -> Result { - match &term.0 { - Term::Literal(s) => Ok(Doc::text(s.to_string())), - Term::NamedNode(n) => Ok(Doc::text(n.to_string())), - Term::BlankNode(b) => self.bnode(b, &term.1), - Term::Collection(items) => self.collection(items), - Term::Invalid => Err(FormatError("cannot format turtle with invalid terms")), - Term::Variable(_) => Err(FormatError("cannot format turtle with variables")), - } - } - - fn bnode(&mut self, bnode: &BlankNode, span: &Range) -> Result { - let pos = match bnode { - BlankNode::Named(x, _) => return Ok(Doc::text(format!("_:{}", x))), - BlankNode::Unnamed(pos, _, _) => pos, - BlankNode::Invalid => return Err(FormatError("invalid blank node")), - }; - if pos.is_empty() { - return Ok(Doc::text("[ ]")); - } - - let forced = self.forces_break_bnode(pos, span); - if pos.len() == 1 && !forced { - // `[ p o ]` when it fits, otherwise break after `[`. - let body = self.comments_doc(pos[0].1.start).append(self.po(&pos[0])?); - return Ok(Doc::text("[") - .append(Doc::line().append(body).nest(self.style.indent)) - .append(Doc::line()) - .append(Doc::text("]")) - .group()); - } - - if pos.len() == 1 { - // Multi-line content: break after `[`, no trailing `;`. - let body = self.comments_doc(pos[0].1.start).append(self.po(&pos[0])?); - return Ok(Doc::text("[") - .append(Doc::hardline().append(body).nest(self.style.indent)) - .append(Doc::hardline()) - .append(Doc::text("]"))); - } - - let mut body = Doc::nil(); - for po in pos { - body = body - .append(Doc::hardline()) - .append(self.comments_doc(po.1.start)) - .append(self.po(po)?) - .append(Doc::text(";")); - } - Ok(Doc::text("[") - .append(body.nest(self.style.indent)) - .append(Doc::hardline()) - .append(Doc::text("]"))) - } - - fn collection(&mut self, items: &[Spanned]) -> Result { - if items.is_empty() { - return Ok(Doc::text("( )")); - } - // `( a b )` when it fits, otherwise one item per line. - let mut body = Doc::nil(); - for item in items { - body = body - .append(Doc::line()) - .append(self.comments_doc(item.1.start)) - .append(self.term(item)?); - } - Ok(Doc::text("(") - .append(body.nest(self.style.indent)) - .append(Doc::line()) - .append(Doc::text(")")) - .group()) - } - - fn is_atomic(term: &Term) -> bool { - match term { - Term::BlankNode(BlankNode::Unnamed(pos, _, _)) => pos.is_empty(), - Term::Collection(items) => items.is_empty(), - _ => true, - } - } - - fn po(&mut self, po: &PO) -> Result { - let mut doc = Doc::text(format!("{} ", po.predicate.0)); - let Some((first, rest)) = po.object.split_first() else { - return Err(FormatError("predicate without object")); - }; - doc = doc.append(self.term(first)?); - - if po.object.iter().all(|o| Self::is_atomic(&o.0)) { - // All on one line, or every object on its own (indented) line. - let mut tail = Doc::nil(); - for o in rest { - tail = tail - .append(Doc::text(",")) - .append(Doc::line()) - .append(self.comments_doc(o.1.start)) - .append(self.term(o)?); - } - Ok(doc.append(tail.nest(self.style.indent)).group()) - } else { - // Nested structures lay themselves out; keep `], [` together. - for o in rest { - doc = doc - .append(Doc::text(", ")) - .append(self.comments_doc(o.1.start)) - .append(self.term(o)?); - } - Ok(doc) - } - } - - fn triple(&mut self, triple: &Triple) -> Result { - let mut doc = match &triple.subject.0 { - Term::BlankNode(bn) => self.bnode(bn, &triple.subject.1)?, - Term::NamedNode(n) => Doc::text(n.to_string()), - _ => Doc::text("invalid"), - }; - let Some((first, rest)) = triple.po.split_first() else { - return Ok(doc.append(Doc::text("."))); - }; - doc = doc.append(Doc::text(" ")).append(self.po(first)?); - - let mut tail = Doc::nil(); - for po in rest { - tail = tail - .append(Doc::text(";")) - .append(Doc::hardline()) - .append(self.comments_doc(po.1.start)) - .append(self.po(po)?); - } - Ok(doc - .append(tail.nest(self.style.indent)) - .append(Doc::text("."))) - } - - fn render(&self, doc: Doc) -> String { - let mut out = String::new(); - // Writing to a String cannot fail. - let _ = doc.render_fmt(self.style.max_width, &mut out); - out + fn node(&self, node: &SyntaxNode, fmt: &Formatter<'_, Lang>) -> Option { + self.directive(node).or_else(|| TurtleHook.node(node, fmt)) } +} - fn turtle(&mut self, turtle: &Turtle, prefix_style: PrefixStyle) -> Result { - let mut out = String::new(); - let mut line_count = 0; - let push_line = |out: &mut String, line_count: &mut usize, s: &str| { - out.push_str(s); - out.push('\n'); - *line_count += s.matches('\n').count() + 1; +impl PrefixStyleHook { + fn directive(&self, node: &SyntaxNode) -> Option { + let text = |kind| { + node.descendants_with_tokens() + .find(|e| e.kind() == kind && e.as_token().is_some()) + .map(|e| e.as_token().unwrap().text().to_string()) }; - - if let Some(ref b) = turtle.base { - for c in self.take_comments(b.1.start) { - push_line(&mut out, &mut line_count, c); - } - push_line(&mut out, &mut line_count, &self.base(b, prefix_style)); - } - for p in &turtle.prefixes { - for c in self.take_comments(p.1.start) { - push_line(&mut out, &mut line_count, c); + // Leave declarations with comments inside as they are. + if text(SyntaxKind::Comment).is_some() { + return None; + } + let iri = text(SyntaxKind::Iriref)?; + let out = match (node.kind(), self.style) { + (SyntaxKind::PrefixId | SyntaxKind::SparqlPrefix, style) => { + let prefix = text(SyntaxKind::PnameNs)?; + match style { + PrefixStyle::OldSchool => format!("@prefix {prefix} {iri}."), + PrefixStyle::Sparql => format!("PREFIX {prefix} {iri}"), + } } - push_line(&mut out, &mut line_count, &self.prefix(p, prefix_style)); - } - - // Separate a statement from the previous one with a blank line - // when the previous chunk spanned multiple lines. - let mut prev_line = 0; - for t in &turtle.triples { - if prev_line + 1 < line_count { - push_line(&mut out, &mut line_count, ""); + (SyntaxKind::Base | SyntaxKind::SparqlBase, PrefixStyle::OldSchool) => { + format!("@base {iri}.") } - prev_line = line_count; - for c in self.take_comments(t.1.start) { - push_line(&mut out, &mut line_count, c); + (SyntaxKind::Base | SyntaxKind::SparqlBase, PrefixStyle::Sparql) => { + format!("BASE {iri}") } - let doc = self.triple(t)?; - let rendered = self.render(doc); - push_line(&mut out, &mut line_count, &rendered); - } - push_line(&mut out, &mut line_count, ""); - - for c in self.take_comments(usize::MAX) { - push_line(&mut out, &mut line_count, c); - } - - Ok(out) - } -} - -pub fn format_turtle( - turtle: &Turtle, - config: FormattingOptions, - comments: &[Spanned], - _source: &LineIndex, - prefix_style: impl Into, -) -> Option { - let mut builder = DocBuilder { - style: Style::new(&config), - comments, - comments_idx: 0, - }; - match builder.turtle(turtle, prefix_style.into()) { - Ok(out) => { - tracing::debug!("Format successful"); - Some(out) - } - Err(e) => { - warn!("Format unsuccessful: {}", e.0); - None - } + _ => return None, + }; + Some(Doc::text(out)) } } #[cfg(test)] mod tests { + use super::{format_turtle, PrefixStyle}; + use crate::lang::parser::parse_new; - use std::str::FromStr; - - use rdf_parsers::turtle::SyntaxKind; - use rowan::NodeOrToken; - use swls_core::prelude::Spanned; - use swls_core::text::LineIndex; - - use crate::lang::{ - formatter::{format_turtle, PrefixStyle}, - parser::parse_new, - Turtle, - }; - - fn parse_turtle(inp: &str, url: &swls_core::lsp_types::Url) -> (Turtle, Vec>) { - let (turtle, errs, _, syntax, _) = parse_new(inp, url.as_str(), None); - for e in &errs { - println!("Parse error: {:?}", e); - } - - // Extract comments from the CST - let mut comments: Vec> = Vec::new(); - for node_or_token in syntax.descendants_with_tokens() { - if let NodeOrToken::Token(t) = node_or_token { - if t.kind() == SyntaxKind::Comment { - let range = t.text_range(); - let span = usize::from(range.start())..usize::from(range.end()); - let text = t.text().to_string(); - comments.push(swls_core::prelude::spanned(text, span)); - } - } - } - comments.sort_by_key(|x| x.1.start); - - (turtle, comments) - } - - #[test] - fn easy_format() { - let txt = r#" -@prefix rdf: . -@prefix rdf: . -@base . - -[] a foaf:Name; - foaf:knows ;. -"#; - - let expected = r#"@base . -@prefix rdf: . -@prefix rdf: . - -[ ] a foaf:Name; - foaf:knows . - -"#; - - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - assert_eq!(formatted, expected); + fn format(input: &str, style: PrefixStyle) -> String { + let (_, _, _, syntax, _) = parse_new(input, "http://example.com/ns#", None); + format_turtle(&syntax, style) } #[test] - fn harder_format_pos() { - let txt = r#" -[] a foaf:Name; - foaf:knows ; foaf:knows2 . - -"#; - - let expected = r#"[ ] a foaf:Name; - foaf:knows ; - foaf:knows2 . - -"#; - - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - assert_eq!(formatted, expected); + fn writes_prefixes_in_turtle_style() { + let input = + "PREFIX foaf: \nBASE \n foaf:knows ."; + let expected = + "@prefix foaf: .\n@base .\n\n foaf:knows .\n"; + assert_eq!(format(input, PrefixStyle::OldSchool), expected); } #[test] - fn format_blanknodes() { - let txt = r#" - [ foaf:Person; foaf:knows ; foaf:knows ] foaf:knows [ - a foaf:Person; - foaf:knows ; - foaf:knows ; - ] . - -"#; - - let expected = r#"[ - foaf:Person; - foaf:knows ; - foaf:knows ; -] foaf:knows [ - a foaf:Person; - foaf:knows ; - foaf:knows ; -]. - -"#; - - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - assert_eq!(formatted, expected); + fn writes_prefixes_in_sparql_style() { + let input = + "@prefix foaf: .\n@base .\n foaf:knows ."; + let expected = + "PREFIX foaf: \nBASE \n\n foaf:knows .\n"; + assert_eq!(format(input, PrefixStyle::Sparql), expected); } #[test] - fn long_objectlist() { - let txt = r#" - a , , , , . -"#; - - let expected = r#" a , - , - , - , - . - -"#; - - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - assert_eq!(formatted, expected); - } - - #[test] - fn short_collection() { - let txt = r#" - a (), ( ). -"#; - - let expected = r#" a ( ), ( ). - -"#; - - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - assert_eq!(formatted, expected); - } - - #[test] - fn long_collection() { - let txt = r#" - a (), ( ). -"#; - - let expected = r#" a ( ), ( - - - - -). - -"#; - - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - assert_eq!(formatted, expected); - } - - #[test] - fn easy_comments() { - let txt = r#" -# Test this is a cool test or something! - # Another comment! - -[] a foaf:Name; - foaf:knows ; foaf:knows2 . - -"#; - - let expected = r#"# Test this is a cool test or something! -# Another comment! -[ ] a foaf:Name; - foaf:knows ; - foaf:knows2 . - -"#; - - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - println!("OUtput {:?}", output); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - assert_eq!(formatted, expected); - } - - #[test] - fn hard_comments() { - let txt = r#" - -[] a foaf:Name; # Nested comment - foaf:knows ; # Another comment! - foaf:knows2 . - - #trailing comments -"#; - - let expected = r#"[ ] a foaf:Name; - # Nested comment - foaf:knows ; - # Another comment! - foaf:knows2 . - -#trailing comments -"#; - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - assert_eq!(formatted, expected); - } - - #[test] - fn bug_1() { - let txt = r#" -[] a sh:NodeShape; - sh:targetClass js:Echo; - sh:property [ - sh:class :ReaderChannel; - sh:path js:input; - sh:name "Input Channel" - ], [ - sh:class :WriterChannel; - sh:path js:output; - sh:name "Output Channel" - ]. - -"#; - - let expected = r#"[ ] a sh:NodeShape; - sh:targetClass js:Echo; - sh:property [ - sh:class :ReaderChannel; - sh:path js:input; - sh:name "Input Channel"; - ], [ - sh:class :WriterChannel; - sh:path js:output; - sh:name "Output Channel"; - ]. - -"#; - - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - assert_eq!(formatted, expected); - } - - #[test] - fn format_nested_bnodes() { - let txt = r#"[] sh:or ( [ sh:property [ - sh:path crm:P4_has_time-span; - sh:minCount 1; - ] ] [ sh:property [ - sh:path crm:P7_took_place_at; - sh:minCount 1; - ] ] ). - "#; - - let expected = r#"[ ] sh:or ( - [ - sh:property [ - sh:path crm:P4_has_time-span; - sh:minCount 1; - ] - ] - [ - sh:property [ - sh:path crm:P7_took_place_at; - sh:minCount 1; - ] - ] -). - -"#; - - let url = swls_core::lsp_types::Url::from_str("http://example.com/ns#").unwrap(); - let (output, comments) = parse_turtle(txt, &url); - let formatted = format_turtle( - &output, - swls_core::lsp_types::FormattingOptions { - tab_size: 2, - ..Default::default() - }, - &comments, - &LineIndex::new(txt), - PrefixStyle::OldSchool, - ) - .expect("formatting"); - - assert_eq!(formatted, expected); + fn keeps_layout_rules() { + // Layout itself is tested in rdf-parsers; this checks the hook chain. + let input = "[] a ;

[ ; ], ;. # done"; + let expected = "[ ] a ;\n

[\n ;\n ;\n ], . # done\n"; + assert_eq!(format(input, PrefixStyle::OldSchool), expected); } } From 5901c47a4e4a1fcba971388312ed9346669f82e5 Mon Sep 17 00:00:00 2001 From: ajuvercr Date: Wed, 14 Oct 2026 14:16:51 +0200 Subject: [PATCH 3/6] feat: format TriG by default Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- core/src/components/config.rs | 18 +++++++++--------- lang-trig/src/ecs/mod.rs | 15 +++++---------- 3 files changed, 16 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 1baafb5871..6fd617bc51 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ win: a workspace file overrides the global one, which overrides `initializationO "turtle": true, "sparql": false, "disabled": ["unused_prefix", "hover_excluded_property"], - "format": { "trig": true, "sparql": true }, + "format": { "sparql": true }, "prefix_format": "sparql" } ``` @@ -85,7 +85,7 @@ win: a workspace file overrides the global one, which overrides `initializationO These are only read from `initializationOptions`. - `disabled` — a list of individual diagnostics or LSP (sub-)features to turn off, e.g. just the "unused prefix" warning, or just hover-on-class without touching the rest of hover. -- `format` — turn document formatting on or off per language: `turtle` (default `true`), `trig`, +- `format` — turn document formatting on or off per language: `turtle`, `trig` (default `true`), `sparql`, `n3`, `jsonld` (default `false`). - `prefix_format` — `"turtle"` (default) writes `@prefix ex: <…>.`, `"sparql"` writes `PREFIX ex: <…>`. Used when inserting prefixes, and the Turtle formatter rewrites existing diff --git a/core/src/components/config.rs b/core/src/components/config.rs index 6ff9226a1b..cbc2362af2 100644 --- a/core/src/components/config.rs +++ b/core/src/components/config.rs @@ -171,8 +171,8 @@ pub struct Config { /// Per-language toggles for `textDocument/formatting`. /// /// Each field is an `Option`, but the *effective* default differs per -/// language: Turtle formatting is on unless explicitly disabled, while every -/// other language is off unless explicitly enabled. Use the accessor methods +/// language: Turtle and TriG formatting are on unless explicitly disabled, +/// while every other language is off unless explicitly enabled. Use the accessor methods /// ([`FormatConfig::turtle`] etc.) to resolve a field to its effective value /// rather than reading the `Option` directly. #[derive(Debug, Deserialize, Serialize, Default)] @@ -181,7 +181,7 @@ pub struct FormatConfig { /// Enable Turtle formatting (default: `true`). #[serde(skip_serializing_if = "Option::is_none")] pub turtle: Option, - /// Enable TriG formatting (default: `false`). + /// Enable TriG formatting (default: `true`). #[serde(skip_serializing_if = "Option::is_none")] pub trig: Option, /// Enable N3 formatting (default: `false`). @@ -217,9 +217,9 @@ impl FormatConfig { pub fn turtle(&self) -> bool { self.turtle.unwrap_or(true) } - /// Whether TriG formatting is enabled (defaults to `false`). + /// Whether TriG formatting is enabled (defaults to `true`). pub fn trig(&self) -> bool { - self.trig.unwrap_or(false) + self.trig.unwrap_or(true) } /// Whether N3 formatting is enabled (defaults to `false`). pub fn n3(&self) -> bool { @@ -251,7 +251,7 @@ pub struct LocalConfig { /// Preferred way to write Turtle/TriG prefix declarations when inserting them. pub prefix_format: Option, /// Per-language `textDocument/formatting` toggles. Formatting is disabled by - /// default for every language except Turtle. + /// default for every language except Turtle and TriG. pub format: FormatConfig, /// Namespaces for which IRIs used as properties (predicates) must be defined in a /// known ontology. Predicate IRIs that start with one of these namespaces but are @@ -477,14 +477,14 @@ mod tests { serde_json::from_str(r#"{ "format": { "sparql": true } }"#).unwrap(); assert!(local.format.sparql()); assert!(local.format.turtle(), "unset toggles keep their defaults"); - assert!(!local.format.trig()); + assert!(!local.format.n3()); } #[test] fn format_from_initialization_options() { let config: Config = - serde_json::from_value(serde_json::json!({ "format": { "trig": true } })).unwrap(); - assert!(config.local.format.trig()); + serde_json::from_value(serde_json::json!({ "format": { "trig": false } })).unwrap(); + assert!(!config.local.format.trig()); } #[test] diff --git a/lang-trig/src/ecs/mod.rs b/lang-trig/src/ecs/mod.rs index 07d97fa391..8a31ad695b 100644 --- a/lang-trig/src/ecs/mod.rs +++ b/lang-trig/src/ecs/mod.rs @@ -205,20 +205,15 @@ mod tests { } #[test] - fn trig_formatting_off_by_default() { - assert!( - format_with(None).is_none(), - "TriG formatting should be disabled unless explicitly enabled" - ); + fn trig_formatting_on_by_default() { + assert_eq!(format_with(None).as_deref(), Some(" ;\n .\n")); } #[test] - fn trig_formatting_can_be_enabled() { - let formatted = format_with(Some(true)); + fn trig_formatting_can_be_disabled() { assert!( - formatted.is_some(), - "TriG formatting should run when enabled via config" + format_with(Some(false)).is_none(), + "TriG formatting should not run when disabled via config" ); - assert!(formatted.unwrap().contains(" ;")); } } From 5edd28dcbfaed020de0322a68ad17f5f95d2e4b8 Mon Sep 17 00:00:00 2001 From: ajuvercr Date: Wed, 14 Oct 2026 14:29:30 +0200 Subject: [PATCH 4/6] feat: parse .ru files as SPARQL updates; test N3 formatting `.ru` files hold SPARQL updates, which failed to parse as queries, so they showed false syntax errors and were never formatted. They are now parsed with the update grammar. Also covers the new N3 formatter from rdf-parsers with a test. Co-Authored-By: Claude Opus 5.5 --- lang-n3/src/ecs/mod.rs | 24 +++++++++++++++++++++ lang-sparql/src/ecs/mod.rs | 43 +++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/lang-n3/src/ecs/mod.rs b/lang-n3/src/ecs/mod.rs index 8e62d2865b..48fbe6fa6b 100644 --- a/lang-n3/src/ecs/mod.rs +++ b/lang-n3/src/ecs/mod.rs @@ -230,6 +230,30 @@ mod tests { messages ); } + + #[test] + fn n3_formatting_uses_n3_rules() { + let (mut world, _rx) = setup_world(TestClient::new(), crate::setup_world::); + { + let mut cfg = world.resource_mut::(); + cfg.config.n3 = Some(true); + cfg.config.local.format.n3 = Some(true); + } + let src = "@prefix ex: . { ?x ex:parent ?y } => { ?x ex:child ?y }. ex:a ex:p ex:b; ex:q ex:c."; + let entity = create_file(&mut world, src, "http://example.com/rules.n3", "n3", Open); + world.run_schedule(ParseLabel); + world.entity_mut(entity).insert(FormatRequest(None)); + world.run_schedule(FormatLabel); + let formatted = world + .entity_mut(entity) + .take::() + .and_then(|r| r.0) + .map(|edits| edits[0].new_text.clone()); + assert_eq!( + formatted.as_deref(), + Some("@prefix ex: .\n\n{ ?x ex:parent ?y } => { ?x ex:child ?y }.\n\nex:a ex:p ex:b;\n ex:q ex:c.\n") + ); + } } pub(crate) fn format_n3_system( diff --git a/lang-sparql/src/ecs/mod.rs b/lang-sparql/src/ecs/mod.rs index ee46b64d47..017e1b89ed 100644 --- a/lang-sparql/src/ecs/mod.rs +++ b/lang-sparql/src/ecs/mod.rs @@ -116,9 +116,16 @@ fn parse_sparql_system( parser::{Lang, Rule, SyntaxKind}, }; + // `.ru` files hold SPARQL updates (INSERT DATA, DELETE ... WHERE, ...), + // which are not queries. + let unit = if label.0.path().ends_with(".ru") { + SyntaxKind::UpdateUnit + } else { + SyntaxKind::QueryUnit + }; let prev = prev_infos.get(label.as_str()); let (parse, new_prev) = rdf_parsers::parse_incremental( - Rule::new(SyntaxKind::QueryUnit), + Rule::new(unit), source.0.as_str(), prev, IncrementalBias::default(), @@ -289,6 +296,40 @@ mod format_tests { .map(|edits| edits[0].new_text.clone()) } + fn format_update(src: &str) -> (bool, Option) { + let (mut world, _rx) = setup_world(TestClient::new(), crate::setup_world); + let mut cfg = ServerConfig::default(); + cfg.config.local.format.sparql = Some(true); + world.insert_resource(cfg); + + let entity = create_file(&mut world, src, "http://example.com/update.ru", "sparql", Open); + world.run_schedule(ParseLabel); + let dirty = world.entity(entity).contains::(); + + world.entity_mut(entity).insert(FormatRequest(None)); + world.run_schedule(FormatLabel); + let formatted = world + .entity_mut(entity) + .take::() + .and_then(|r| r.0) + .map(|edits| edits[0].new_text.clone()); + (dirty, formatted) + } + + #[test] + fn update_files_parse_and_format() { + let (dirty, formatted) = format_update( + "PREFIX ex: INSERT DATA { ex:a ex:b ex:c } ; DELETE { ?s ex:p ?o } WHERE { ?s ex:p ?o . FILTER(?o>1) }", + ); + assert!(!dirty, "a valid update should parse without errors"); + assert_eq!( + formatted.as_deref(), + Some( + "PREFIX ex: \n\nINSERT DATA {\n ex:a ex:b ex:c\n};\n\nDELETE {\n ?s ex:p ?o\n}\nWHERE {\n ?s ex:p ?o.\n FILTER (?o > 1)\n}\n" + ) + ); + } + #[test] fn sparql_formatting_off_by_default() { assert!( From 6a77d99069d501851e83fabde10b7af6ea79ba40 Mon Sep 17 00:00:00 2001 From: ajuvercr Date: Wed, 14 Oct 2026 15:30:46 +0200 Subject: [PATCH 5/6] feat: enable formatting by default for all languages Turtle, TriG, SPARQL, N3 and JSON-LD now format unless disabled with `format.: false`. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +++--- core/src/components/config.rs | 37 +++++++++++++++++------------------ lang-n3/src/ecs/mod.rs | 1 - lang-sparql/src/ecs/mod.rs | 12 ++++++------ 4 files changed, 27 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 6fd617bc51..a6f91d3a09 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ win: a workspace file overrides the global one, which overrides `initializationO "turtle": true, "sparql": false, "disabled": ["unused_prefix", "hover_excluded_property"], - "format": { "sparql": true }, + "format": { "jsonld": false }, "prefix_format": "sparql" } ``` @@ -85,8 +85,8 @@ win: a workspace file overrides the global one, which overrides `initializationO These are only read from `initializationOptions`. - `disabled` — a list of individual diagnostics or LSP (sub-)features to turn off, e.g. just the "unused prefix" warning, or just hover-on-class without touching the rest of hover. -- `format` — turn document formatting on or off per language: `turtle`, `trig` (default `true`), - `sparql`, `n3`, `jsonld` (default `false`). +- `format` — turn document formatting off per language: `turtle`, `trig`, `sparql`, `n3`, + `jsonld` (all default `true`). - `prefix_format` — `"turtle"` (default) writes `@prefix ex: <…>.`, `"sparql"` writes `PREFIX ex: <…>`. Used when inserting prefixes, and the Turtle formatter rewrites existing declarations to match. diff --git a/core/src/components/config.rs b/core/src/components/config.rs index cbc2362af2..e4998aab5c 100644 --- a/core/src/components/config.rs +++ b/core/src/components/config.rs @@ -170,11 +170,10 @@ pub struct Config { /// Per-language toggles for `textDocument/formatting`. /// -/// Each field is an `Option`, but the *effective* default differs per -/// language: Turtle and TriG formatting are on unless explicitly disabled, -/// while every other language is off unless explicitly enabled. Use the accessor methods -/// ([`FormatConfig::turtle`] etc.) to resolve a field to its effective value -/// rather than reading the `Option` directly. +/// Each field is an `Option`; formatting is on for every language unless +/// explicitly disabled. Use the accessor methods ([`FormatConfig::turtle`] +/// etc.) to resolve a field to its effective value rather than reading the +/// `Option` directly. #[derive(Debug, Deserialize, Serialize, Default)] #[serde(default)] pub struct FormatConfig { @@ -184,13 +183,13 @@ pub struct FormatConfig { /// Enable TriG formatting (default: `true`). #[serde(skip_serializing_if = "Option::is_none")] pub trig: Option, - /// Enable N3 formatting (default: `false`). + /// Enable N3 formatting (default: `true`). #[serde(skip_serializing_if = "Option::is_none")] pub n3: Option, - /// Enable JSON-LD formatting (default: `false`). + /// Enable JSON-LD formatting (default: `true`). #[serde(skip_serializing_if = "Option::is_none")] pub jsonld: Option, - /// Enable SPARQL formatting (default: `false`). + /// Enable SPARQL formatting (default: `true`). #[serde(skip_serializing_if = "Option::is_none")] pub sparql: Option, } @@ -221,17 +220,17 @@ impl FormatConfig { pub fn trig(&self) -> bool { self.trig.unwrap_or(true) } - /// Whether N3 formatting is enabled (defaults to `false`). + /// Whether N3 formatting is enabled (defaults to `true`). pub fn n3(&self) -> bool { - self.n3.unwrap_or(false) + self.n3.unwrap_or(true) } - /// Whether JSON-LD formatting is enabled (defaults to `false`). + /// Whether JSON-LD formatting is enabled (defaults to `true`). pub fn jsonld(&self) -> bool { - self.jsonld.unwrap_or(false) + self.jsonld.unwrap_or(true) } - /// Whether SPARQL formatting is enabled (defaults to `false`). + /// Whether SPARQL formatting is enabled (defaults to `true`). pub fn sparql(&self) -> bool { - self.sparql.unwrap_or(false) + self.sparql.unwrap_or(true) } } @@ -250,8 +249,8 @@ pub struct LocalConfig { pub completion: CompletionConfig, /// Preferred way to write Turtle/TriG prefix declarations when inserting them. pub prefix_format: Option, - /// Per-language `textDocument/formatting` toggles. Formatting is disabled by - /// default for every language except Turtle and TriG. + /// Per-language `textDocument/formatting` toggles. Formatting is enabled + /// by default for every language. pub format: FormatConfig, /// Namespaces for which IRIs used as properties (predicates) must be defined in a /// known ontology. Predicate IRIs that start with one of these namespaces but are @@ -474,10 +473,10 @@ mod tests { #[test] fn format_from_config_file() { let local: LocalConfig = - serde_json::from_str(r#"{ "format": { "sparql": true } }"#).unwrap(); - assert!(local.format.sparql()); + serde_json::from_str(r#"{ "format": { "sparql": false } }"#).unwrap(); + assert!(!local.format.sparql()); assert!(local.format.turtle(), "unset toggles keep their defaults"); - assert!(!local.format.n3()); + assert!(local.format.n3()); } #[test] diff --git a/lang-n3/src/ecs/mod.rs b/lang-n3/src/ecs/mod.rs index 48fbe6fa6b..cece8e6cc2 100644 --- a/lang-n3/src/ecs/mod.rs +++ b/lang-n3/src/ecs/mod.rs @@ -237,7 +237,6 @@ mod tests { { let mut cfg = world.resource_mut::(); cfg.config.n3 = Some(true); - cfg.config.local.format.n3 = Some(true); } let src = "@prefix ex: . { ?x ex:parent ?y } => { ?x ex:child ?y }. ex:a ex:p ex:b; ex:q ex:c."; let entity = create_file(&mut world, src, "http://example.com/rules.n3", "n3", Open); diff --git a/lang-sparql/src/ecs/mod.rs b/lang-sparql/src/ecs/mod.rs index 017e1b89ed..0be113d251 100644 --- a/lang-sparql/src/ecs/mod.rs +++ b/lang-sparql/src/ecs/mod.rs @@ -299,7 +299,7 @@ mod format_tests { fn format_update(src: &str) -> (bool, Option) { let (mut world, _rx) = setup_world(TestClient::new(), crate::setup_world); let mut cfg = ServerConfig::default(); - cfg.config.local.format.sparql = Some(true); + cfg.config.local.format.sparql = None; world.insert_resource(cfg); let entity = create_file(&mut world, src, "http://example.com/update.ru", "sparql", Open); @@ -331,17 +331,17 @@ mod format_tests { } #[test] - fn sparql_formatting_off_by_default() { + fn sparql_formatting_can_be_disabled() { assert!( - format_with(None).is_none(), - "SPARQL formatting should be disabled unless explicitly enabled" + format_with(Some(false)).is_none(), + "SPARQL formatting should not run when disabled via config" ); } #[test] - fn sparql_formatting_can_be_enabled() { + fn sparql_formatting_on_by_default() { assert_eq!( - format_with(Some(true)).as_deref(), + format_with(None).as_deref(), Some("SELECT ?s\nWHERE {\n ?s ?o.\n FILTER (?o > 5)\n}\n") ); } From 3699ff445d3e05ec89154137829477a5650537fe Mon Sep 17 00:00:00 2001 From: ajuvercr Date: Wed, 14 Oct 2026 20:49:39 +0200 Subject: [PATCH 6/6] chore: update rdf-parsers --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e938dacb2c..c3465229c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,8 +15,8 @@ bevy_ecs = { version = "0.18", default-features = true, features = [ derive_more = { version = "2", features = ["as_ref", "deref", "deref_mut", "is_variant"] } futures = { version = "0.3.31", features = ["alloc", "executor"], default-features = false } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } -rdf-parsers = { path = "../rdf-parsers/" } -# rdf-parsers = { version = "0.1.16" } +# rdf-parsers = { path = "../rdf-parsers/" } +rdf-parsers = { version = "0.2.0" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0.145" sophia_api = "0.9.0"