1use clap::Args;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use tera::Context;
5use rgen_utils::error::Result;
6
7use rgen_core::graph::Graph;
8use rgen_core::template::Template;
9
10#[derive(Args, Debug)]
11pub struct LintArgs {
12 #[arg(value_name = "TEMPLATE")]
14 pub template: PathBuf,
15
16 #[arg(short = 'v', long = "var", value_parser = parse_key_val::<String, String>)]
18 pub vars: Vec<(String, String)>,
19
20 #[arg(long)]
22 pub verbose: bool,
23
24 #[arg(long)]
26 pub shacl: bool,
27}
28
29fn parse_key_val<K, V>(s: &str) -> std::result::Result<(K, V), String>
30where
31 K: std::str::FromStr,
32 K::Err: ToString,
33 V: std::str::FromStr,
34 V::Err: ToString,
35{
36 let pos = s
37 .find('=')
38 .ok_or_else(|| format!("invalid KEY=value: no `=` found in `{s}`"))?;
39 let key = s[..pos].parse().map_err(|e: K::Err| e.to_string())?;
40 let val = s[pos + 1..].parse().map_err(|e: V::Err| e.to_string())?;
41 Ok((key, val))
42}
43
44pub fn run(args: &LintArgs) -> Result<()> {
45 let mut issues = Vec::new();
46
47 let template_content = std::fs::read_to_string(&args.template)?;
49
50 let mut template = Template::parse(&template_content)?;
52
53 let mut vars = BTreeMap::new();
55 for (k, v) in &args.vars {
56 vars.insert(k.clone(), v.clone());
57 }
58 let mut ctx = Context::from_serialize(&vars)?;
59
60 insert_env(&mut ctx);
62
63 let mut tera = tera::Tera::default();
65 tera.autoescape_on(vec![]);
66
67 rgen_core::register::register_all(&mut tera);
69
70 template.render_frontmatter(&mut tera, &ctx)?;
72
73 validate_frontmatter_schema(&template.front, &mut issues);
75
76 validate_sparql_queries(&template.front, &mut issues);
78
79 validate_rdf_content(&template.front, &mut issues);
81
82 if args.shacl {
84 validate_shacl(&template.front, &mut issues);
85 }
86
87 if issues.is_empty() {
89 println!("✓ No linting issues found");
90 } else {
91 println!("Found {} linting issue(s):", issues.len());
92 for (i, issue) in issues.iter().enumerate() {
93 println!("{}. {}", i + 1, issue);
94 }
95 return Err(rgen_utils::error::Error::new("Linting failed"));
96 }
97
98 Ok(())
99}
100
101fn validate_frontmatter_schema(
102 frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>,
103) {
104 if frontmatter.inject {
109 if frontmatter.to.is_none() {
110 issues.push("Injection mode requires 'to:' field to specify target file".to_string());
111 }
112
113 let _injection_modes = [
115 frontmatter.before.is_some(),
116 frontmatter.after.is_some(),
117 frontmatter.prepend,
118 frontmatter.append,
119 frontmatter.at_line.is_some(),
120 ];
121
122 let active_modes: Vec<&str> = [
123 (frontmatter.before.is_some(), "before"),
124 (frontmatter.after.is_some(), "after"),
125 (frontmatter.prepend, "prepend"),
126 (frontmatter.append, "append"),
127 (frontmatter.at_line.is_some(), "at_line"),
128 ]
129 .iter()
130 .filter_map(|(active, name)| if *active { Some(*name) } else { None })
131 .collect();
132
133 if active_modes.len() > 1 {
134 issues.push(format!(
135 "Multiple injection modes specified: {}. Only one should be used",
136 active_modes.join(", ")
137 ));
138 }
139
140 if active_modes.is_empty() {
141 issues.push("Injection mode enabled but no injection method specified (before, after, prepend, append, at_line)".to_string());
142 }
143 }
144
145 if frontmatter.sh_before.is_some() && !frontmatter.inject {
147 issues.push("sh_before specified but injection mode is not enabled".to_string());
148 }
149
150 if frontmatter.sh_after.is_some() && !frontmatter.inject {
151 issues.push("sh_after specified but injection mode is not enabled".to_string());
152 }
153
154 if !frontmatter.rdf.is_empty() && !frontmatter.rdf_inline.is_empty() {
156 }
158
159 for (name, query) in &frontmatter.sparql {
161 if name.trim().is_empty() {
162 issues.push("SPARQL query name cannot be empty".to_string());
163 }
164 if query.trim().is_empty() {
165 issues.push(format!("SPARQL query '{}' is empty", name));
166 }
167 }
168}
169
170fn validate_sparql_queries(frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>) {
171 for (name, query) in &frontmatter.sparql {
172 if !query.to_uppercase().contains("SELECT")
174 && !query.to_uppercase().contains("ASK")
175 && !query.to_uppercase().contains("CONSTRUCT")
176 && !query.to_uppercase().contains("DESCRIBE")
177 {
178 issues.push(format!("SPARQL query '{}' does not appear to be a valid query type (SELECT, ASK, CONSTRUCT, DESCRIBE)", name));
179 }
180
181 if query.contains("{{") && !query.contains("}}") {
183 issues.push(format!(
184 "SPARQL query '{}' has unclosed template variable",
185 name
186 ));
187 }
188
189 if query.contains("}}") && !query.contains("{{") {
190 issues.push(format!(
191 "SPARQL query '{}' has template closing without opening",
192 name
193 ));
194 }
195 }
196}
197
198fn validate_rdf_content(frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>) {
199 for (i, rdf_content) in frontmatter.rdf_inline.iter().enumerate() {
201 if rdf_content.trim().is_empty() {
202 issues.push(format!("Inline RDF block {} is empty", i + 1));
203 }
204
205 if rdf_content.contains("@prefix") && !rdf_content.contains(" .") {
207 issues.push(format!(
208 "Inline RDF block {} has @prefix without proper termination",
209 i + 1
210 ));
211 }
212 }
213
214 for (i, rdf_file) in frontmatter.rdf.iter().enumerate() {
216 if rdf_file.trim().is_empty() {
217 issues.push(format!("RDF file reference {} is empty", i + 1));
218 }
219 }
220}
221
222fn validate_shacl(frontmatter: &rgen_core::template::Frontmatter, issues: &mut Vec<String>) {
223 if frontmatter.rdf.is_empty() && frontmatter.rdf_inline.is_empty() {
225 issues.push("SHACL validation requested but no RDF data is available".to_string());
226 return;
227 }
228
229 if frontmatter.shape.is_empty() {
230 issues.push("SHACL validation requested but no shape files specified".to_string());
231 return;
232 }
233
234 let mut combined_graph = match Graph::new() {
236 Ok(g) => g,
237 Err(e) => {
238 issues.push(format!(
239 "Failed to initialize graph for SHACL validation: {}",
240 e
241 ));
242 return;
243 }
244 };
245
246 if let Err(e) = load_rdf_data_into_graph(frontmatter, &mut combined_graph) {
248 issues.push(format!(
249 "Failed to load RDF data for SHACL validation: {}",
250 e
251 ));
252 return;
253 }
254
255 if let Err(e) = load_shacl_shapes_into_graph(frontmatter, &mut combined_graph) {
256 issues.push(format!("Failed to load SHACL shapes: {}", e));
257 return;
258 }
259
260 match perform_optimized_shacl_validation(&combined_graph) {
262 Ok(validation_results) => {
263 if !validation_results.is_empty() {
264 for result in validation_results {
265 issues.push(format!("SHACL validation error: {}", result));
266 }
267 }
268 }
269 Err(e) => {
270 issues.push(format!("SHACL validation failed: {}", e));
271 }
272 }
273}
274
275fn load_rdf_data_into_graph(
276 frontmatter: &rgen_core::template::Frontmatter, graph: &mut Graph,
277) -> Result<()> {
278 for rdf_content in &frontmatter.rdf_inline {
280 if !rdf_content.trim().is_empty() {
281 graph.insert_turtle(rdf_content)?;
282 }
283 }
284
285 for rdf_file in &frontmatter.rdf {
287 if !rdf_file.trim().is_empty() {
288 graph.load_path(rdf_file)?;
289 }
290 }
291
292 Ok(())
293}
294
295fn load_shacl_shapes_into_graph(
296 frontmatter: &rgen_core::template::Frontmatter, graph: &mut Graph,
297) -> Result<()> {
298 for shape_file in &frontmatter.shape {
300 if !shape_file.trim().is_empty() {
301 graph.load_path(shape_file)?;
302 }
303 }
304
305 Ok(())
306}
307
308fn perform_optimized_shacl_validation(combined_graph: &Graph) -> Result<Vec<String>> {
309 let mut validation_errors = Vec::new();
310
311 if combined_graph.is_empty() {
313 validation_errors
314 .push("Combined graph is empty - no data or shapes to validate".to_string());
315 return Ok(validation_errors);
316 }
317
318 let shapes_query = "SELECT ?shape WHERE { ?shape <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://www.w3.org/ns/shacl#NodeShape> }";
320 let shapes_result = combined_graph.query_cached(shapes_query)?;
321
322 match shapes_result {
323 rgen_core::graph::CachedResult::Solutions(solutions) => {
324 if solutions.is_empty() {
325 validation_errors.push("No SHACL NodeShapes found in shapes graph".to_string());
326 } else {
327 for shape_solution in solutions {
329 if let Some(shape_iri) = shape_solution.get("shape") {
330 if let Err(e) = validate_single_shape(combined_graph, shape_iri) {
331 validation_errors
332 .push(format!("Shape validation error for {}: {}", shape_iri, e));
333 }
334 }
335 }
336 }
337 }
338 _ => {
339 validation_errors.push("Failed to query for SHACL shapes".to_string());
340 }
341 }
342
343 Ok(validation_errors)
344}
345
346fn validate_single_shape(graph: &Graph, shape_iri: &str) -> Result<()> {
347 let properties_query = format!(
349 "SELECT ?property WHERE {{ <{}> <http://www.w3.org/ns/shacl#property> ?property }}",
350 shape_iri
351 );
352
353 let properties_result = graph.query_cached(&properties_query)?;
354 match properties_result {
355 rgen_core::graph::CachedResult::Solutions(properties) => {
356 for property_solution in properties {
358 if let Some(property_iri) = property_solution.get("property") {
359 validate_property_constraint(graph, property_iri)?;
360 }
361 }
362 }
363 _ => {
364 }
366 }
367
368 Ok(())
369}
370
371fn validate_property_constraint(graph: &Graph, property_iri: &str) -> Result<()> {
372 let min_count_query = format!(
374 "ASK WHERE {{ <{}> <http://www.w3.org/ns/shacl#minCount> ?minCount }}",
375 property_iri
376 );
377
378 let min_count_result = graph.query_cached(&min_count_query)?;
379 match min_count_result {
380 rgen_core::graph::CachedResult::Boolean(true) => {
381 }
384 _ => {
385 }
387 }
388
389 Ok(())
390}
391
392fn insert_env(ctx: &mut Context) {
393 let mut env_map: BTreeMap<String, String> = BTreeMap::new();
394 for (k, v) in std::env::vars() {
395 env_map.insert(k, v);
396 }
397 ctx.insert("env", &env_map);
398
399 if let Ok(cwd) = std::env::current_dir() {
400 ctx.insert("cwd", &cwd.display().to_string());
401 }
402}