carve-lang 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2d4e85e4ab93621b11f1502ca546d64cc0d46e513e4074e6e4014cec8f5aee04
4
- data.tar.gz: 3100558ccb654d5c2441358ce8a13ba363db7937e0e979be3944780322b62561
3
+ metadata.gz: cd1a19b5989abf287804723f52ff9b4f6ab7098be2d7a6f893caffa7e9ca4f26
4
+ data.tar.gz: 1c503d1890015fd5f129d8464405a6f66a594517026abbf588474fec96ad8e5e
5
5
  SHA512:
6
- metadata.gz: db44ca3900ce90483edd037944efa79adf6c55a921afe8b00b00a31ba687d6f0f5fce97efba6ec3fb67e69f3c931544bd6b0e09169d62e9a8549b99e0836c693
7
- data.tar.gz: 28dbc892917d7fb9074005e71cff53fe603bc25ca4d3a54add3042be7813aa4bed7e2db0eea5b950b1a5e2c416de9d9588c38ac7f8985cb90aa126f482d17c86
6
+ metadata.gz: 80e49b48040288e254718b55f452a0f0d8a3beab8246a7e50f4f8fb3db50d65597453389a0fcbbdb1610a54f99f51c2659da63b7a60482f700c82fe522b92642
7
+ data.tar.gz: 03e5140ca7a00cc0e71c5ca0db95cfabfd223cb15d77cfb456373f8538948c6ba6b12e882d0f81843d466b369a4b13fc64ae9cdd19e1fd59ed22ef9b2deac857
data/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.11] - 2026-10-23
11
+
12
+ ### Improvements
13
+
14
+ - `Carve.lint(source, extensions:)` exposes the engine's linter: findings carry
15
+ the rule id, message, 1-based line and column, and character offsets. With
16
+ `root:` and `source_path:` it lints included files and reports each finding's
17
+ file and `includedBy` chain (#223).
18
+
10
19
  ## [0.1.10] - 2026-10-23
11
20
 
12
21
  ### Breaking
@@ -527,7 +536,8 @@ are not listed, because no release ever shipped them.
527
536
  Arrays (every AST node type is covered), enabling custom renderers such as
528
537
  [carve-hexapdf](https://github.com/markup-carve/carve-hexapdf).
529
538
 
530
- [Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.10...HEAD
539
+ [Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.11...HEAD
540
+ [0.1.11]: https://github.com/markup-carve/carve-rb/compare/v0.1.10...v0.1.11
531
541
  [0.1.10]: https://github.com/markup-carve/carve-rb/compare/v0.1.9...v0.1.10
532
542
  [0.1.9]: https://github.com/markup-carve/carve-rb/compare/v0.1.8...v0.1.9
533
543
  [0.1.8]: https://github.com/markup-carve/carve-rb/compare/v0.1.7...v0.1.8
data/README.md CHANGED
@@ -140,6 +140,21 @@ Other render methods leave directives literal. See the
140
140
  [include reference](docs/reference.md#file-includes) for budgets, AST expansion,
141
141
  targets, and containment behavior.
142
142
 
143
+ ## Lint
144
+
145
+ `Carve.lint(source)` reports constructs that render but lose what the author
146
+ wrote, such as an unattached attribute or a link to a missing id:
147
+
148
+ ```ruby
149
+ Carve.lint("é [x](#nope)\n").map { |f| [f[:rule], f[:line], f[:column]] }
150
+ # => [["broken-fragment-link", 1, 3]]
151
+ ```
152
+
153
+ Columns and the `:start`/`:end` offsets count characters, not bytes. Pass
154
+ `root:` and `source_path:` to lint included files too; each such finding names
155
+ its file and the `:includedBy` chain. See the
156
+ [lint reference](docs/reference.md#linting).
157
+
143
158
  ## Security
144
159
 
145
160
  Use `safe: true` and a restrictive profile for untrusted documents. Custom
data/ext/carve/Cargo.lock CHANGED
@@ -54,7 +54,7 @@ dependencies = [
54
54
 
55
55
  [[package]]
56
56
  name = "carve-rb"
57
- version = "0.1.10"
57
+ version = "0.1.11"
58
58
  dependencies = [
59
59
  "carve-lang",
60
60
  "magnus",
data/ext/carve/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "carve-rb"
3
- version = "0.1.10"
3
+ version = "0.1.11"
4
4
  edition = "2021"
5
5
  rust-version = "1.75"
6
6
  publish = false
data/ext/carve/src/lib.rs CHANGED
@@ -297,28 +297,16 @@ fn parse_include_target(ruby: &Ruby, name: &str) -> Result<IncludeTarget, Error>
297
297
  }
298
298
  }
299
299
 
300
- #[allow(clippy::too_many_arguments)]
301
- fn render_with_includes_json(
300
+ /// Canonicalize an include root and the document's own path, and refuse a
301
+ /// document outside the root. Both must be absolute: a relative one would root
302
+ /// containment at the process working directory.
303
+ fn contained_include_paths(
302
304
  ruby: &Ruby,
303
- source: String,
304
- root: String,
305
- source_path: String,
306
- target: String,
307
- names: RArray,
308
- mode: String,
309
- renderers: RHash,
310
- symbols: RHash,
311
- safe: bool,
312
- profile: Option<String>,
313
- sections: bool,
314
- max_depth: Option<usize>,
315
- max_bytes: Option<usize>,
316
- max_resolver_calls: Option<usize>,
317
- max_warnings: Option<usize>,
318
- ) -> Result<String, Error> {
319
- let which = parse_include_target(ruby, &target)?;
320
- let root_path = Path::new(&root);
321
- let source_path = Path::new(&source_path);
305
+ root: &str,
306
+ source_path: &str,
307
+ ) -> Result<(PathBuf, PathBuf), Error> {
308
+ let root_path = Path::new(root);
309
+ let source_path = Path::new(source_path);
322
310
  if !root_path.is_absolute() || !source_path.is_absolute() {
323
311
  return Err(Error::new(
324
312
  ruby.exception_arg_error(),
@@ -343,6 +331,42 @@ fn render_with_includes_json(
343
331
  "include source_path must be a file inside the include root".to_string(),
344
332
  ));
345
333
  }
334
+ Ok((root_real, source_real))
335
+ }
336
+
337
+ /// A path as a reader sees it: relative to the include root.
338
+ fn root_relative(root_real: &Path, value: &str) -> String {
339
+ let path = PathBuf::from(value);
340
+ if path.is_absolute() {
341
+ path.strip_prefix(root_real)
342
+ .map(|relative| relative.to_string_lossy().replace('\\', "/"))
343
+ .unwrap_or_else(|_| "[outside-root]".to_string())
344
+ } else {
345
+ value.to_string()
346
+ }
347
+ }
348
+
349
+ #[allow(clippy::too_many_arguments)]
350
+ fn render_with_includes_json(
351
+ ruby: &Ruby,
352
+ source: String,
353
+ root: String,
354
+ source_path: String,
355
+ target: String,
356
+ names: RArray,
357
+ mode: String,
358
+ renderers: RHash,
359
+ symbols: RHash,
360
+ safe: bool,
361
+ profile: Option<String>,
362
+ sections: bool,
363
+ max_depth: Option<usize>,
364
+ max_bytes: Option<usize>,
365
+ max_resolver_calls: Option<usize>,
366
+ max_warnings: Option<usize>,
367
+ ) -> Result<String, Error> {
368
+ let which = parse_include_target(ruby, &target)?;
369
+ let (root_real, source_real) = contained_include_paths(ruby, &root, &source_path)?;
346
370
 
347
371
  let resolver = carve_rs::FileSystemResolver::new(&root_real).map_err(|_| {
348
372
  Error::new(
@@ -443,16 +467,7 @@ fn render_with_includes_json(
443
467
  } else {
444
468
  serde_json::Value::String(rendered)
445
469
  };
446
- let safe_path = |value: &str| {
447
- let path = PathBuf::from(value);
448
- if path.is_absolute() {
449
- path.strip_prefix(&root_real)
450
- .map(|relative| relative.to_string_lossy().replace('\\', "/"))
451
- .unwrap_or_else(|_| "[outside-root]".to_string())
452
- } else {
453
- value.to_string()
454
- }
455
- };
470
+ let safe_path = |value: &str| root_relative(&root_real, value);
456
471
  let warnings = prepared
457
472
  .warnings
458
473
  .iter()
@@ -495,6 +510,122 @@ fn render_with_includes_json(
495
510
  .to_string())
496
511
  }
497
512
 
513
+ /// Byte offset -> codepoint offset over `source`.
514
+ ///
515
+ /// `LintWarning` reports `start`/`end` in bytes so a Rust caller can slice its
516
+ /// `&str`; a Ruby String indexes by character, so a byte offset would mis-slice
517
+ /// every document with a non-ASCII character before the finding. A byte inside a
518
+ /// character maps to that character, and the final slot is the total.
519
+ fn codepoint_table(source: &str) -> Vec<usize> {
520
+ let mut table = Vec::with_capacity(source.len() + 1);
521
+ let mut count = 0usize;
522
+ for ch in source.chars() {
523
+ for _ in 0..ch.len_utf8() {
524
+ table.push(count);
525
+ }
526
+ count += 1;
527
+ }
528
+ table.push(count);
529
+ table
530
+ }
531
+
532
+ #[allow(clippy::too_many_arguments)]
533
+ fn lint_json(
534
+ ruby: &Ruby,
535
+ source: String,
536
+ names: RArray,
537
+ root: Option<String>,
538
+ source_path: Option<String>,
539
+ max_depth: Option<usize>,
540
+ max_bytes: Option<usize>,
541
+ max_resolver_calls: Option<usize>,
542
+ max_warnings: Option<usize>,
543
+ ) -> Result<String, Error> {
544
+ let boxed = boxed_extensions(ruby, names)?;
545
+ let mut options = Options::new();
546
+ for extension in &boxed {
547
+ options = options.with_extension(extension.as_ref());
548
+ }
549
+ // The document's own findings come first in `lint_carve_with_options`, and
550
+ // they are the only ones whose offsets index `source`. Linting it alone
551
+ // first tells the two apart without guessing from a finding's fields.
552
+ let own = carve_rs::lint_carve_with_options(&source, &options);
553
+ let own_count = own.len();
554
+ let (warnings, root_real) = match (root, source_path) {
555
+ (None, None) => (own, None),
556
+ (Some(root), Some(source_path)) => {
557
+ let (root_real, source_real) = contained_include_paths(ruby, &root, &source_path)?;
558
+ let resolver = carve_rs::FileSystemResolver::new(&root_real).map_err(|_| {
559
+ Error::new(
560
+ ruby.exception_arg_error(),
561
+ "include root is not usable".to_string(),
562
+ )
563
+ })?;
564
+ let mut include_options = carve_rs::IncludeOptions::new()
565
+ .with_resolver(&resolver)
566
+ .with_source_path(source_real.to_string_lossy());
567
+ if let Some(value) = max_depth {
568
+ include_options = include_options.with_max_depth(value);
569
+ }
570
+ if let Some(value) = max_bytes {
571
+ include_options = include_options.with_max_bytes(value);
572
+ }
573
+ if let Some(value) = max_resolver_calls {
574
+ include_options = include_options.with_max_resolver_calls(value);
575
+ }
576
+ if let Some(value) = max_warnings {
577
+ include_options = include_options.with_max_warnings(value);
578
+ }
579
+ let options = options.with_lint_includes(include_options);
580
+ (
581
+ carve_rs::lint_carve_with_options(&source, &options),
582
+ Some(root_real),
583
+ )
584
+ }
585
+ _ => {
586
+ return Err(Error::new(
587
+ ruby.exception_arg_error(),
588
+ "lint needs both root: and source_path: to follow includes".to_string(),
589
+ ))
590
+ }
591
+ };
592
+ let table = codepoint_table(&source);
593
+ let total = table.len() - 1;
594
+ let to_codepoint = |byte: usize| table.get(byte).copied().unwrap_or(total);
595
+ let path = |value: &str| match &root_real {
596
+ Some(root_real) => root_relative(root_real, value),
597
+ None => value.to_string(),
598
+ };
599
+ let findings = warnings
600
+ .iter()
601
+ .enumerate()
602
+ .map(|(index, warning)| {
603
+ let own = index < own_count;
604
+ serde_json::json!({
605
+ "rule": warning.rule,
606
+ "message": warning.message,
607
+ "line": warning.line,
608
+ "column": warning.column,
609
+ "start": own.then(|| to_codepoint(warning.start)),
610
+ "end": own.then(|| to_codepoint(warning.end)),
611
+ "file": warning.file.as_deref().map(&path),
612
+ "includedBy": warning
613
+ .included_by
614
+ .iter()
615
+ .map(|site| {
616
+ serde_json::json!({
617
+ "file": path(&site.file),
618
+ "line": site.line,
619
+ "column": site.column,
620
+ })
621
+ })
622
+ .collect::<Vec<_>>(),
623
+ })
624
+ })
625
+ .collect::<Vec<_>>();
626
+ Ok(serde_json::Value::Array(findings).to_string())
627
+ }
628
+
498
629
  fn from_html_json(ruby: &Ruby, source: String, mode: String) -> Result<String, Error> {
499
630
  let mode = carve_rs::HtmlImportMode::from_name(&mode).ok_or_else(|| {
500
631
  Error::new(
@@ -1024,6 +1155,20 @@ guarded!(
1024
1155
  ) -> String =>? render_with_includes_json
1025
1156
  );
1026
1157
 
1158
+ guarded!(
1159
+ g_lint_json(
1160
+ ruby: &Ruby,
1161
+ source: Source,
1162
+ names: RArray,
1163
+ root: Option<String>,
1164
+ source_path: Option<String>,
1165
+ max_depth: Option<usize>,
1166
+ max_bytes: Option<usize>,
1167
+ max_resolver_calls: Option<usize>,
1168
+ max_warnings: Option<usize>
1169
+ ) -> String =>? lint_json
1170
+ );
1171
+
1027
1172
  /// Entry point invoked by Ruby when the extension is loaded.
1028
1173
  ///
1029
1174
  /// `name = "carve"` makes the macro emit the `Init_carve` symbol that matches
@@ -1073,5 +1218,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1073
1218
  module.define_singleton_method("_read_stamp", function!(g_read_stamp, 1))?;
1074
1219
  module.define_singleton_method("_stamp_needs_review", function!(g_stamp_needs_review, 2))?;
1075
1220
  module.define_singleton_method("_panic_probe", function!(g_panic_probe, 0))?;
1221
+ module.define_singleton_method("_lint_json", function!(g_lint_json, 8))?;
1076
1222
  Ok(())
1077
1223
  }
data/lib/carve/version.rb CHANGED
@@ -8,5 +8,5 @@ module Carve
8
8
  # compares it against ext/carve/Cargo.toml and against the newest cut
9
9
  # CHANGELOG section on every run, and .github/workflows/release.yml refuses to
10
10
  # publish a gem whose version is not the tag being released.
11
- VERSION = "0.1.10"
11
+ VERSION = "0.1.11"
12
12
  end
data/lib/carve.rb CHANGED
@@ -150,6 +150,55 @@ module Carve
150
150
  )
151
151
  end
152
152
 
153
+ # Report constructs that parse and render, but not the way the author
154
+ # meant: an attribute that attaches to nothing, an unclosed fence, a
155
+ # fragment link with no target.
156
+ #
157
+ # Carve.lint("Text.\n\n{#gone}\n")
158
+ # # => [{rule: "unattached-block-attribute", message: "...", line: 3,
159
+ # # column: 1, start: 7, end: 14, file: nil, includedBy: []}]
160
+ #
161
+ # Returns an Array of Hashes, empty for a clean document:
162
+ #
163
+ # * +:rule+ - the engine's stable rule id, the same in every Carve engine.
164
+ # * +:message+ - what degrades, in prose.
165
+ # * +:line+, +:column+ - 1-based; the column counts characters, not bytes.
166
+ # * +:start+, +:end+ - 0-based character offsets into +source+, end
167
+ # exclusive, so <tt>source[f[:start]...f[:end]]</tt> is the offending text.
168
+ # +nil+ on a finding the include pass adds (below), whose offsets would
169
+ # index another text.
170
+ # * +:file+, +:includedBy+ - see below; +nil+ and +[]+ without includes.
171
+ #
172
+ # The engine reports no severity: every finding is a warning.
173
+ #
174
+ # Pass the same +extensions:+ you render with, since they change which
175
+ # constructs are elements and which rules apply.
176
+ #
177
+ # ==== Following includes
178
+ #
179
+ # With +root:+ and +source_path:+ (absolute, as for .to_html_with_includes)
180
+ # the pass also lints every included Carve file and reports include
181
+ # directives that stay literal (a missing target, a cycle, a budget). Such a
182
+ # finding names its +:file+ relative to the root and its +:includedBy+ chain,
183
+ # top-level first, as +{file:, line:, column:}+. The budgets
184
+ # (+max_depth:+ and the rest) mean what they mean for rendering.
185
+ def lint(source, extensions: nil, root: nil, source_path: nil, max_depth: nil,
186
+ max_bytes: nil, max_resolver_calls: nil, max_warnings: nil)
187
+ JSON.parse(
188
+ _lint_json(
189
+ source.to_s,
190
+ Array(extensions).map(&:to_s),
191
+ root&.to_s,
192
+ source_path&.to_s,
193
+ max_depth,
194
+ max_bytes,
195
+ max_resolver_calls,
196
+ max_warnings,
197
+ ),
198
+ symbolize_names: true,
199
+ )
200
+ end
201
+
153
202
  # Format +source+ into canonical Carve. A writer refusal returns the source
154
203
  # as authored. +align_tables:+ pads table cells into aligned columns.
155
204
  def to_carve(source, align_tables: false)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carve-lang
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.10
4
+ version: 0.1.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - markup-carve