carve-lang 0.1.9 → 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: 796f44a79683bfd2b7add107652d71a7d38aaf6d4aedf7169acde4fa2401af26
4
- data.tar.gz: d7f0ba287fc9c13eab535e7e376de3096f1baedfd1df415b5f870ec7bfca395f
3
+ metadata.gz: cd1a19b5989abf287804723f52ff9b4f6ab7098be2d7a6f893caffa7e9ca4f26
4
+ data.tar.gz: 1c503d1890015fd5f129d8464405a6f66a594517026abbf588474fec96ad8e5e
5
5
  SHA512:
6
- metadata.gz: aec21e3a2aafe3fd84c6debf48af398ea241610d606b954c770a84bd6b5e38b90b0b1d056ecb4c47f8ba9ec976d85498a5d3013ab6885e1b9fe8fdae572d516b
7
- data.tar.gz: 6adc9afe363cd5c135a649b6f336cf41a55b6197a4cb8927273fd57fe4e0c484d47bbe159a8469eea11dc49a41771637ca048712bd596a9bd9515e099e0f611a
6
+ metadata.gz: 80e49b48040288e254718b55f452a0f0d8a3beab8246a7e50f4f8fb3db50d65597453389a0fcbbdb1610a54f99f51c2659da63b7a60482f700c82fe522b92642
7
+ data.tar.gz: 03e5140ca7a00cc0e71c5ca0db95cfabfd223cb15d77cfb456373f8538948c6ba6b12e882d0f81843d466b369a4b13fc64ae9cdd19e1fd59ed22ef9b2deac857
data/CHANGELOG.md CHANGED
@@ -7,6 +7,54 @@ 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
+
19
+ ## [0.1.10] - 2026-10-23
20
+
21
+ ### Breaking
22
+
23
+ - The engine is the published `carve-lang` 0.1.11 crate. A quote-prefixed `+`
24
+ needs one space after each `>`, so `>+` and `>>+` now render as text, and a
25
+ continuation marker reads only at column 0 or right after a block opener.
26
+ Re-check documents that place `+` elsewhere (#215, markup-carve/carve#3421).
27
+ - Includes of a file that is not valid UTF-8 are refused with an
28
+ `include-non-text` warning instead of being spliced (#215,
29
+ markup-carve/carve-rs#2885).
30
+
31
+ ### Fixes
32
+
33
+ - The carve-lang 0.1.11 pin corrects container ownership for quotes, lazy lines,
34
+ colon divs and footnotes, fences inside nested lists and quotes, and list
35
+ tightness around captions (#215).
36
+ - Includes see through a byte order mark and keep link definitions and region
37
+ markers in place. Markdown import keeps link destinations, autolink brackets
38
+ and pipe escapes as written (#215).
39
+ - `Carve.render_with_includes` with the `markdown`, `plain` and `ansi` targets
40
+ keeps a link definition where it was written, as `Carve.to_markdown` does,
41
+ instead of moving it to the end (#221).
42
+
43
+ ### Improvements
44
+
45
+ - Precompiled gems for `x86_64-linux`, `x86_64-linux-musl`, `aarch64-linux`,
46
+ `x86_64-darwin`, `arm64-darwin` and `x64-mingw-ucrt` install without a Rust
47
+ toolchain on Ruby 3.0 to 4.0 (#214).
48
+ - `Carve.to_carve(source, align_tables: true)` pads table columns to display
49
+ width (#215).
50
+ - `Carve.select_include_region(source, name, mode:)` resolves a named
51
+ `@region` to a Range of content lines and raises `Carve::IncludeRegionError`
52
+ for a missing or malformed region (#215).
53
+ - An include with `@code` or `@code:lang` splices a file or region as one code
54
+ block (#215).
55
+ - Include warnings carry `includedBy`, the chain of directives that reached the
56
+ file the warning arose in, with root-relative paths, lines and columns (#221).
57
+
10
58
  ## [0.1.9] - 2026-10-18
11
59
 
12
60
  ### Breaking
@@ -488,7 +536,9 @@ are not listed, because no release ever shipped them.
488
536
  Arrays (every AST node type is covered), enabling custom renderers such as
489
537
  [carve-hexapdf](https://github.com/markup-carve/carve-hexapdf).
490
538
 
491
- [Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.9...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
541
+ [0.1.10]: https://github.com/markup-carve/carve-rb/compare/v0.1.9...v0.1.10
492
542
  [0.1.9]: https://github.com/markup-carve/carve-rb/compare/v0.1.8...v0.1.9
493
543
  [0.1.8]: https://github.com/markup-carve/carve-rb/compare/v0.1.7...v0.1.8
494
544
  [0.1.7]: https://github.com/markup-carve/carve-rb/compare/v0.1.6...v0.1.7
data/README.md CHANGED
@@ -16,7 +16,48 @@ bundle install
16
16
  ```
17
17
 
18
18
  The distribution name is `carve-lang`, while the require path is `carve`.
19
- Building from source requires Rust 1.75 or newer and Ruby development headers.
19
+
20
+ Releases after 0.1.9 ship precompiled gems for `x86_64-linux`,
21
+ `x86_64-linux-musl`, `aarch64-linux`, `x86_64-darwin`, `arm64-darwin` and
22
+ `x64-mingw-ucrt`, with a binary for each Ruby from 3.0 to 4.0 (from 3.1 on
23
+ Windows, where Ruby 3.0 has no UCRT build). On those platforms `gem install`
24
+ needs no Rust toolchain or libclang. Everywhere else, including newer Rubies,
25
+ RubyGems installs the source gem and builds it as described below.
26
+
27
+ ## Installing from source
28
+
29
+ Version 0.1.9 and earlier ship source only, and so does every release on a
30
+ platform or Ruby without a precompiled gem. There `gem install carve-lang`
31
+ compiles a Rust extension on your machine, so the install needs:
32
+
33
+ - A Rust toolchain with `cargo`, Rust 1.77 or newer. [rustup](https://rustup.rs)
34
+ is the usual way to get one.
35
+ - Ruby development headers (`ruby-dev` on Debian and Ubuntu).
36
+ - libclang and clang. The build runs `bindgen` through `rb-sys` to read Ruby's
37
+ C headers. On Debian and Ubuntu:
38
+
39
+ ```bash
40
+ sudo apt install libclang-dev clang
41
+ ```
42
+
43
+ If only libclang is installed, without the `clang` package, the build stops
44
+ with this error:
45
+
46
+ ```text
47
+ /usr/include/ruby-3.2.0/ruby/ruby.h:23:10: fatal error: 'stdarg.h' file not found
48
+ thread 'main' panicked at .../rb-sys-0.9.128/build/main.rs:52:6:
49
+ generate bindings: ClangDiagnostic("/usr/include/ruby-3.2.0/ruby/ruby.h:23:10: fatal error: 'stdarg.h' file not found\n")
50
+ error: failed to run custom build command for `rb-sys v0.9.128`
51
+ ```
52
+
53
+ libclang cannot find its own builtin headers. Install `clang`, or point
54
+ bindgen at GCC's copy of those headers:
55
+
56
+ ```bash
57
+ BINDGEN_EXTRA_CLANG_ARGS="-I$(gcc -print-file-name=include)" gem install carve-lang
58
+ ```
59
+
60
+ With Bundler, export the variable before running `bundle install`.
20
61
 
21
62
  ## Render Carve
22
63
 
@@ -28,6 +69,7 @@ Carve.to_markdown(source)
28
69
  Carve.to_plain_text(source)
29
70
  Carve.to_ansi(source)
30
71
  Carve.to_carve(source)
72
+ Carve.to_carve(source, align_tables: true) # pad table columns
31
73
  ```
32
74
 
33
75
  Options configure extensions, safe rendering, profiles, symbols, section
@@ -91,11 +133,28 @@ result = Carve.to_html_with_includes(
91
133
  )
92
134
  ```
93
135
 
94
- The result contains the value, warnings, and root-relative dependencies.
136
+ The result contains the value, warnings, and root-relative dependencies. A
137
+ warning from an included file names the chain of directives that reached it in
138
+ `:includedBy`.
95
139
  Other render methods leave directives literal. See the
96
140
  [include reference](docs/reference.md#file-includes) for budgets, AST expansion,
97
141
  targets, and containment behavior.
98
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
+
99
158
  ## Security
100
159
 
101
160
  Use `safe: true` and a restrictive profile for untrusted documents. Custom
data/ext/carve/Cargo.lock CHANGED
@@ -37,9 +37,9 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
37
37
 
38
38
  [[package]]
39
39
  name = "carve-lang"
40
- version = "0.1.10"
40
+ version = "0.1.11"
41
41
  source = "registry+https://github.com/rust-lang/crates.io-index"
42
- checksum = "4a9b7a2a3b758f76ce1888ad1371bd933c5f2ccded01305c03f1550641a45c1d"
42
+ checksum = "753b90fbe80fab43887d5d559996c153347d9631452bbcb8d0283aad6dfa7ef8"
43
43
  dependencies = [
44
44
  "html5ever",
45
45
  "markup5ever_rcdom",
@@ -54,7 +54,7 @@ dependencies = [
54
54
 
55
55
  [[package]]
56
56
  name = "carve-rb"
57
- version = "0.1.9"
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.9"
3
+ version = "0.1.11"
4
4
  edition = "2021"
5
5
  rust-version = "1.75"
6
6
  publish = false
@@ -22,4 +22,4 @@ serde_json = "1"
22
22
  # The Carve engine. Imported under the Rust crate alias `carve_rs` so the
23
23
  # binding's own crate name (`carve-rb`) does not collide with the engine's
24
24
  # package name (`carve`) in the Cargo lockfile.
25
- carve_rs = { package = "carve-lang", version = "=0.1.10" }
25
+ carve_rs = { package = "carve-lang", version = "=0.1.11" }
data/ext/carve/src/lib.rs CHANGED
@@ -226,8 +226,44 @@ fn to_ansi(source: String) -> String {
226
226
  carve_rs::to_ansi(&source)
227
227
  }
228
228
 
229
- fn to_carve(source: String) -> String {
230
- carve_rs::to_carve(&source)
229
+ /// Canonical Carve source; a writer refusal returns the source as authored.
230
+ fn to_carve(source: String, align_tables: bool) -> String {
231
+ let options = Options::default().with_align_tables(align_tables);
232
+ carve_rs::try_to_carve_with_options(&source, &options).unwrap_or(source)
233
+ }
234
+
235
+ /// Resolve `@region:name` with the engine's marker rules (spec I1b).
236
+ ///
237
+ /// Returns `[start, end]` (1-based, inclusive content lines) or, when the
238
+ /// region is invalid, the warning rule id as a String; `lib/carve.rb` turns
239
+ /// that into `Carve::IncludeRegionError`.
240
+ fn select_include_region(
241
+ ruby: &Ruby,
242
+ source: String,
243
+ name: String,
244
+ mode: String,
245
+ ) -> Result<Value, Error> {
246
+ let mode = match mode.as_str() {
247
+ "carve" => carve_rs::IncludeRegionMode::Carve,
248
+ "code" => carve_rs::IncludeRegionMode::Code,
249
+ other => {
250
+ return Err(Error::new(
251
+ ruby.exception_arg_error(),
252
+ format!("mode must be :carve or :code, got {other:?}"),
253
+ ))
254
+ }
255
+ };
256
+ Ok(
257
+ match carve_rs::select_include_region(&source, &name, mode) {
258
+ Ok(region) => ruby
259
+ .ary_new_from_values(&[
260
+ ruby.integer_from_u64(region.start as u64).as_value(),
261
+ ruby.integer_from_u64(region.end as u64).as_value(),
262
+ ])
263
+ .as_value(),
264
+ Err(e) => ruby.str_new(e.rule()).as_value(),
265
+ },
266
+ )
231
267
  }
232
268
 
233
269
  /// Which renderer runs over the expanded document.
@@ -261,28 +297,16 @@ fn parse_include_target(ruby: &Ruby, name: &str) -> Result<IncludeTarget, Error>
261
297
  }
262
298
  }
263
299
 
264
- #[allow(clippy::too_many_arguments)]
265
- 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(
266
304
  ruby: &Ruby,
267
- source: String,
268
- root: String,
269
- source_path: String,
270
- target: String,
271
- names: RArray,
272
- mode: String,
273
- renderers: RHash,
274
- symbols: RHash,
275
- safe: bool,
276
- profile: Option<String>,
277
- sections: bool,
278
- max_depth: Option<usize>,
279
- max_bytes: Option<usize>,
280
- max_resolver_calls: Option<usize>,
281
- max_warnings: Option<usize>,
282
- ) -> Result<String, Error> {
283
- let which = parse_include_target(ruby, &target)?;
284
- let root_path = Path::new(&root);
285
- 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);
286
310
  if !root_path.is_absolute() || !source_path.is_absolute() {
287
311
  return Err(Error::new(
288
312
  ruby.exception_arg_error(),
@@ -307,6 +331,42 @@ fn render_with_includes_json(
307
331
  "include source_path must be a file inside the include root".to_string(),
308
332
  ));
309
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)?;
310
370
 
311
371
  let resolver = carve_rs::FileSystemResolver::new(&root_real).map_err(|_| {
312
372
  Error::new(
@@ -344,6 +404,12 @@ fn render_with_includes_json(
344
404
  if !sections {
345
405
  render_options = render_options.with_sections(false);
346
406
  }
407
+ // Positions let the engine record the directive chain behind a warning
408
+ // (`includedBy`). The AST target keeps them off: spec I4 leaves position
409
+ // remapping out of scope, so a child's spans would point into the wrong file.
410
+ if which != IncludeTarget::Ast {
411
+ render_options = render_options.with_positions(true);
412
+ }
347
413
  let mut include_options = carve_rs::IncludeOptions::new()
348
414
  .with_resolver(&resolver)
349
415
  .with_source_path(source_real.to_string_lossy());
@@ -401,16 +467,7 @@ fn render_with_includes_json(
401
467
  } else {
402
468
  serde_json::Value::String(rendered)
403
469
  };
404
- let safe_path = |value: &str| {
405
- let path = PathBuf::from(value);
406
- if path.is_absolute() {
407
- path.strip_prefix(&root_real)
408
- .map(|relative| relative.to_string_lossy().replace('\\', "/"))
409
- .unwrap_or_else(|_| "[outside-root]".to_string())
410
- } else {
411
- value.to_string()
412
- }
413
- };
470
+ let safe_path = |value: &str| root_relative(&root_real, value);
414
471
  let warnings = prepared
415
472
  .warnings
416
473
  .iter()
@@ -419,6 +476,17 @@ fn render_with_includes_json(
419
476
  "rule": warning.rule,
420
477
  "message": warning.message,
421
478
  "file": warning.file.as_deref().map(&safe_path),
479
+ "includedBy": warning
480
+ .included_by
481
+ .iter()
482
+ .map(|site| {
483
+ serde_json::json!({
484
+ "file": safe_path(&site.file),
485
+ "line": site.line,
486
+ "column": site.column,
487
+ })
488
+ })
489
+ .collect::<Vec<_>>(),
422
490
  })
423
491
  })
424
492
  .collect::<Vec<_>>();
@@ -442,6 +510,122 @@ fn render_with_includes_json(
442
510
  .to_string())
443
511
  }
444
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
+
445
629
  fn from_html_json(ruby: &Ruby, source: String, mode: String) -> Result<String, Error> {
446
630
  let mode = carve_rs::HtmlImportMode::from_name(&mode).ok_or_else(|| {
447
631
  Error::new(
@@ -897,7 +1081,11 @@ guarded!(g_to_html(source: Source) -> String => to_html);
897
1081
  guarded!(g_to_markdown(source: Source) -> String => to_markdown);
898
1082
  guarded!(g_to_plain_text(source: Source) -> String => to_plain_text);
899
1083
  guarded!(g_to_ansi(source: Source) -> String => to_ansi);
900
- guarded!(g_to_carve(source: Source) -> String => to_carve);
1084
+ guarded!(g_to_carve(source: Source, align_tables: bool) -> String => to_carve);
1085
+ guarded!(
1086
+ g_select_include_region(ruby: &Ruby, source: Source, name: String, mode: String) -> Value
1087
+ =>? select_include_region
1088
+ );
901
1089
  guarded!(g_from_markdown_json(ruby: &Ruby, source: Source) -> String =>? from_markdown_json);
902
1090
  guarded!(g_to_ast_json(source: Source) -> String => to_ast_json);
903
1091
  guarded!(g_extension_names() -> Vec<String> => extension_names);
@@ -967,6 +1155,20 @@ guarded!(
967
1155
  ) -> String =>? render_with_includes_json
968
1156
  );
969
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
+
970
1172
  /// Entry point invoked by Ruby when the extension is loaded.
971
1173
  ///
972
1174
  /// `name = "carve"` makes the macro emit the `Init_carve` symbol that matches
@@ -990,7 +1192,11 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
990
1192
  module.define_singleton_method("to_markdown", function!(g_to_markdown, 1))?;
991
1193
  module.define_singleton_method("to_plain_text", function!(g_to_plain_text, 1))?;
992
1194
  module.define_singleton_method("to_ansi", function!(g_to_ansi, 1))?;
993
- module.define_singleton_method("to_carve", function!(g_to_carve, 1))?;
1195
+ module.define_singleton_method("_to_carve", function!(g_to_carve, 2))?;
1196
+ module.define_singleton_method(
1197
+ "_select_include_region",
1198
+ function!(g_select_include_region, 3),
1199
+ )?;
994
1200
  module.define_singleton_method(
995
1201
  "_render_with_includes_json",
996
1202
  function!(g_render_with_includes_json, 15),
@@ -1012,5 +1218,6 @@ fn init(ruby: &Ruby) -> Result<(), Error> {
1012
1218
  module.define_singleton_method("_read_stamp", function!(g_read_stamp, 1))?;
1013
1219
  module.define_singleton_method("_stamp_needs_review", function!(g_stamp_needs_review, 2))?;
1014
1220
  module.define_singleton_method("_panic_probe", function!(g_panic_probe, 0))?;
1221
+ module.define_singleton_method("_lint_json", function!(g_lint_json, 8))?;
1015
1222
  Ok(())
1016
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.9"
11
+ VERSION = "0.1.11"
12
12
  end
data/lib/carve.rb CHANGED
@@ -8,11 +8,30 @@
8
8
  require "json"
9
9
  require_relative "carve/version"
10
10
 
11
- # Load the compiled native extension. Built by `rake compile` to
12
- # lib/carve/carve.so (or .bundle on macOS).
13
- require_relative "carve/carve"
11
+ # Load the compiled native extension. A precompiled platform gem ships one per
12
+ # Ruby ABI under lib/carve/<major.minor>/; a source install and `rake compile`
13
+ # build lib/carve/carve.so (or .bundle on macOS).
14
+ # Chosen by directory rather than by rescuing LoadError, so a binary that is
15
+ # present but cannot load (an older glibc, say) reports its own error.
16
+ abi = RUBY_VERSION[/\A\d+\.\d+/]
17
+ if File.directory?(File.join(__dir__, "carve", abi))
18
+ require_relative "carve/#{abi}/carve"
19
+ else
20
+ require_relative "carve/carve"
21
+ end
14
22
 
15
23
  module Carve
24
+ # Raised by .select_include_region when the region is missing or a marker is
25
+ # malformed, unpaired or duplicated. +rule+ is the engine's warning rule id.
26
+ class IncludeRegionError < ArgumentError
27
+ attr_reader :rule
28
+
29
+ def initialize(rule, name)
30
+ @rule = rule
31
+ super("#{rule}: region #{name.inspect} is missing or its markers are malformed")
32
+ end
33
+ end
34
+
16
35
  # Extension names the native binding understands.
17
36
  #
18
37
  # Taken from the engine itself rather than typed out here. This was a literal
@@ -55,7 +74,8 @@ module Carve
55
74
  #
56
75
  # Returns +{value:, warnings:, dependencies:, suppressedWarnings:}+. Paths
57
76
  # in +warnings+ and +dependencies+ are relative to the root, so a report can
58
- # be shown to a reader as it stands.
77
+ # be shown to a reader as it stands. Each warning's +includedBy+ lists the
78
+ # directives that reached its file, top-level first, as +{file:, line:, column:}+.
59
79
  #
60
80
  # Every render option +to_html+ takes is accepted and reaches the included
61
81
  # children too: an extension, a symbol map or a profile means the same thing
@@ -130,6 +150,77 @@ module Carve
130
150
  )
131
151
  end
132
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
+
202
+ # Format +source+ into canonical Carve. A writer refusal returns the source
203
+ # as authored. +align_tables:+ pads table cells into aligned columns.
204
+ def to_carve(source, align_tables: false)
205
+ _to_carve(source, align_tables ? true : false)
206
+ end
207
+
208
+ # Resolve <tt>@region:name</tt> in a file's text with the engine's marker
209
+ # rules, so an editor jumping to a region agrees with the render.
210
+ #
211
+ # Carve.select_include_region(text, "setup", mode: :carve) # => 3..4
212
+ #
213
+ # Returns the 1-based, inclusive Range of content lines; an empty region is
214
+ # an empty Range such as +2..1+. +mode: :carve+ accepts only
215
+ # <tt>%% #region</tt> markers, +mode: :code+ also the source-comment forms
216
+ # of an +@code+ include. Raises IncludeRegionError otherwise.
217
+ def select_include_region(source, name, mode:)
218
+ result = _select_include_region(source, name.to_s, mode.to_s)
219
+ raise IncludeRegionError.new(result, name.to_s) if result.is_a?(String)
220
+
221
+ result[0]..result[1]
222
+ end
223
+
133
224
  # Import HTML or Markdown into canonical Carve. Both methods return the
134
225
  # shared migration shape `{ value:, report: }`.
135
226
  def from_html(source, mode: :safe)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carve-lang
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.1.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - markup-carve
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-18 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys