carve-lang 0.1.6 → 0.1.7

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: 7fa1a68f65aa5ecd3b0ca6554071fa31a2db0b9f57789a65ee5e2f3d8af8b77f
4
- data.tar.gz: 2d9cec2f07553546dcecaac2f603db42b559512dafa0d183e5a5c297561b4e25
3
+ metadata.gz: e1129dd70ab09658be69bfbbf0158a02d599d9e814fb5e3f015be6d1a6cc5621
4
+ data.tar.gz: bc87753903ba603238e2c766b20c8384259dc8f44e87da90af4ef3f6d26ac613
5
5
  SHA512:
6
- metadata.gz: bd2a0359ee4083b50daf8da92144cdcb02f72a3edc7d67b79d52b3e37ced9a73443b229170e7758e610db406bee5065a3e9c449da4494601c963b087c44e4b06
7
- data.tar.gz: 81355dccbf314c449d49fc64ffc41344d193ad28c51e1707249e7128b1dae2689cbef4f553460e2e20a0bf8afca42cdf52849709753a84dab55e8d8f20397743
6
+ metadata.gz: f4f5180ccfda64b312e035b7302db7de20b52293466dd1719715d7ab9fcdfa06ba61b23e993d042df746ced604219bdb78cd5931e09a6dd25388ee9fffcac102
7
+ data.tar.gz: c1276c1504398c7be637fc9e6699b42217e0a3845f49e7647348650701abdc25148fe2138c49643171154d8834c184ef711b294bd3f3215db29de509299e127c
data/CHANGELOG.md CHANGED
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.7] - 2026-10-07
11
+
12
+ ### Changed
13
+
14
+ - The engine is the published `carve-lang` 0.1.8 crate, up from 0.1.7. It closes
15
+ every row `resources/spec-drift.txt` declared: the gem renders all 2225 corpus
16
+ documents spec main declares byte-identically, where 0.1.7 rendered 30 of them
17
+ by a superseded rule. The ledger is empty again, which is the only state a tag
18
+ may ship.
19
+ - **Breaking for a `Carve.to_html` consumer:** a heading cross-reference, a
20
+ numbered caption or equation reference, and a collapsed reference falling back
21
+ to heading text compare their target case exactly. `</#target>` against a
22
+ `# Target` heading no longer resolves and stays literal text; link-definition
23
+ labels, footnote labels and include fragment selectors already matched case
24
+ exactly. `carve fmt --migrate` in carve-rs repairs unambiguous case-only misses
25
+ (markup-carve/carve#2732, markup-carve/carve-rs#2320).
26
+ - **Breaking for a `Carve.to_html` consumer:** a glossary reference matches its
27
+ term exactly and links to the matched entry, and two terms differing only in
28
+ case take two ids (markup-carve/carve#2739).
29
+ - A render that blanks a denied destination scheme reports one
30
+ `destination-denied` loss, so a checked render of a `javascript:` destination
31
+ refuses where it used to pass. The emitted `href=""` does not move
32
+ (markup-carve/carve#2679).
33
+
34
+ ### Added
35
+
36
+ - `Carve::EnginePanic`, a `StandardError` subclass raised when the engine
37
+ panics, so a host rendering untrusted input can rescue one and keep serving.
38
+ The message carries the panic text and its location in the engine source, and
39
+ the usual panic report still reaches stderr (#170).
40
+
41
+ ### Fixed
42
+
43
+ - An engine panic arrives as a rescuable Ruby exception rather than `fatal`.
44
+ magnus catches the unwind but raises it as `fatal`, which no host can stop,
45
+ not even with `rescue Exception`, so any panic ended the process. The binding
46
+ now converts a caught panic itself (#170).
47
+ - A line holding a single `|` followed by an attribute block, such as `|{.r}`,
48
+ renders as paragraph text. Under the 0.1.7 engine it panicked inside the table
49
+ check, and a panic crossing the FFI boundary reached Ruby as `fatal`, which
50
+ `rescue` cannot catch, so five bytes of input terminated the host process
51
+ (markup-carve/carve-rs#2341).
52
+
10
53
  ## [0.1.6] - 2026-09-29
11
54
 
12
55
  ### Changed
@@ -401,7 +444,8 @@ are not listed, because no release ever shipped them.
401
444
  Arrays (every AST node type is covered), enabling custom renderers such as
402
445
  [carve-hexapdf](https://github.com/markup-carve/carve-hexapdf).
403
446
 
404
- [Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.6...HEAD
447
+ [Unreleased]: https://github.com/markup-carve/carve-rb/compare/v0.1.7...HEAD
448
+ [0.1.7]: https://github.com/markup-carve/carve-rb/compare/v0.1.6...v0.1.7
405
449
  [0.1.6]: https://github.com/markup-carve/carve-rb/compare/v0.1.5...v0.1.6
406
450
  [0.1.5]: https://github.com/markup-carve/carve-rb/compare/v0.1.4...v0.1.5
407
451
  [0.1.4]: https://github.com/markup-carve/carve-rb/compare/v0.1.3...v0.1.4
data/README.md CHANGED
@@ -45,6 +45,29 @@ Carve.to_html(
45
45
  `Carve::EXTENSIONS` reports the names accepted by the bundled engine. Unknown
46
46
  names raise `ArgumentError`.
47
47
 
48
+ ## Errors
49
+
50
+ An invalid argument raises `ArgumentError`, and a render the engine refuses -
51
+ input past a profile's `max_length`, or a denied construct - also raises
52
+ `ArgumentError` with the engine's reason.
53
+
54
+ If the engine panics, the call raises `Carve::EnginePanic`, a `StandardError`
55
+ subclass carrying the panic message and its location in the engine source. A
56
+ panic means the engine reached a state it believed impossible, so the document
57
+ is not renderable, but the process survives and a host can serve an error
58
+ instead of losing the worker:
59
+
60
+ ``` ruby
61
+ begin
62
+ Carve.to_html(untrusted_source)
63
+ rescue Carve::EnginePanic => e
64
+ logger.error("carve engine panic: #{e.message}")
65
+ "<p>This document could not be rendered.</p>"
66
+ end
67
+ ```
68
+
69
+ Report any input that raises it: a panic is an engine defect, not a rejection.
70
+
48
71
  ## Migration and AST access
49
72
 
50
73
  `Carve.from_html` and `Carve.from_markdown` return canonical Carve with
data/ext/carve/Cargo.lock CHANGED
@@ -37,22 +37,24 @@ checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
37
37
 
38
38
  [[package]]
39
39
  name = "carve-lang"
40
- version = "0.1.7"
40
+ version = "0.1.8"
41
41
  source = "registry+https://github.com/rust-lang/crates.io-index"
42
- checksum = "bade620457149d669d9e7b89c3952fa5c44fcbefac2d4de78b75f64792ef1774"
42
+ checksum = "0889683244be278df2ff8e1cd88e9f932036a424bb8d838546636b7f11e93903"
43
43
  dependencies = [
44
44
  "html5ever",
45
45
  "markup5ever_rcdom",
46
+ "memchr",
46
47
  "pulldown-cmark",
47
48
  "regex",
48
49
  "serde",
49
50
  "serde_json",
51
+ "smallvec",
50
52
  "unicode-normalization",
51
53
  ]
52
54
 
53
55
  [[package]]
54
56
  name = "carve-rb"
55
- version = "0.1.6"
57
+ version = "0.1.7"
56
58
  dependencies = [
57
59
  "carve-lang",
58
60
  "magnus",
data/ext/carve/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "carve-rb"
3
- version = "0.1.6"
3
+ version = "0.1.7"
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.7" }
25
+ carve_rs = { package = "carve-lang", version = "=0.1.8" }
data/ext/carve/src/lib.rs CHANGED
@@ -27,8 +27,12 @@
27
27
  use carve_rs::extensions::registry;
28
28
  use carve_rs::{CarveExtension, Mode, Options, Profile, StaticRenderers};
29
29
  use magnus::value::{InnerValue, Opaque};
30
- use magnus::{function, prelude::*, Error, RArray, RHash, Ruby, Value};
30
+ use magnus::{function, prelude::*, Error, ExceptionClass, RArray, RHash, Ruby, Value};
31
+ use std::any::Any;
32
+ use std::cell::RefCell;
33
+ use std::panic::{self, AssertUnwindSafe};
31
34
  use std::path::{Path, PathBuf};
35
+ use std::sync::Once;
32
36
 
33
37
  /// HTML-escape a string for the renderer-failure fallback path.
34
38
  ///
@@ -732,6 +736,192 @@ fn stamp_needs_review(source: String, current_version: Option<String>) -> bool {
732
736
  carve_rs::needs_review(&source, &current)
733
737
  }
734
738
 
739
+ /// Panic deliberately inside the extension, so a test can observe the FFI
740
+ /// panic contract rather than assuming it.
741
+ ///
742
+ /// Nothing but a test calls this. It exists because no Carve input panics the
743
+ /// pinned engine: the one that did (`|{.r}`) is fixed, and a test that cannot
744
+ /// reach a panic cannot tell a working safety net from a missing one.
745
+ fn panic_probe() -> String {
746
+ panic!("deliberate panic from the Carve extension panic probe");
747
+ }
748
+
749
+ // ---------------------------------------------------------------------------
750
+ // FFI panic safety
751
+ // ---------------------------------------------------------------------------
752
+ //
753
+ // magnus wraps every exposed call in catch_unwind, but `Error::from_panic`
754
+ // raises the caught panic as Ruby's `fatal`, and Ruby does not let a host stop
755
+ // a `fatal` -- not with `rescue Exception`, not with anything. So the unwind
756
+ // that was supposed to protect the host still ends the process, and magnus
757
+ // offers no way to choose a different class.
758
+ //
759
+ // Everything Ruby can reach therefore runs inside `guard`, which catches the
760
+ // unwind FIRST and raises `Carve::EnginePanic` (a StandardError) instead. The
761
+ // `panic = "unwind"` compile flag is still what makes any of this possible,
762
+ // which is why `scripts/check-panic-unwind.sh` stays.
763
+
764
+ thread_local! {
765
+ /// Where the most recent panic on this thread came from.
766
+ ///
767
+ /// The payload `catch_unwind` hands back carries the message but not the
768
+ /// location, and the location is the half that identifies the engine bug.
769
+ static PANIC_LOCATION: RefCell<Option<String>> = const { RefCell::new(None) };
770
+ }
771
+
772
+ /// Record panic locations without taking over panic reporting.
773
+ ///
774
+ /// The previous hook still runs, so the `thread '<unnamed>' panicked at ...`
775
+ /// line and any `RUST_BACKTRACE` output a developer relies on are unchanged.
776
+ fn install_panic_hook() {
777
+ static ONCE: Once = Once::new();
778
+ ONCE.call_once(|| {
779
+ let previous = panic::take_hook();
780
+ panic::set_hook(Box::new(move |info| {
781
+ let location = info
782
+ .location()
783
+ .map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()));
784
+ PANIC_LOCATION.with(|slot| *slot.borrow_mut() = location);
785
+ previous(info);
786
+ }));
787
+ });
788
+ }
789
+
790
+ /// `Carve::EnginePanic`, or `RuntimeError` if the class cannot be looked up.
791
+ ///
792
+ /// The fallback is still rescuable, which is the property that matters; losing
793
+ /// the specific class is better than losing the process.
794
+ fn engine_panic_class(ruby: &Ruby) -> ExceptionClass {
795
+ ruby.define_module("Carve")
796
+ .ok()
797
+ .and_then(|module| module.const_get::<_, ExceptionClass>("EnginePanic").ok())
798
+ .unwrap_or_else(|| ruby.exception_runtime_error())
799
+ }
800
+
801
+ /// Turn a caught unwind payload into a rescuable Ruby exception.
802
+ fn engine_panic_error(payload: Box<dyn Any + Send>) -> Error {
803
+ let message = if let Some(m) = payload.downcast_ref::<&'static str>() {
804
+ (*m).to_string()
805
+ } else if let Some(m) = payload.downcast_ref::<String>() {
806
+ m.clone()
807
+ } else {
808
+ "panic".to_string()
809
+ };
810
+ let message = match PANIC_LOCATION.with(|slot| slot.borrow_mut().take()) {
811
+ Some(at) => format!("the Carve engine panicked at {at}: {message}"),
812
+ None => format!("the Carve engine panicked: {message}"),
813
+ };
814
+
815
+ // Unchecked for the same reason magnus does it in `Error::from_panic`: this
816
+ // only runs while a Ruby thread is calling into the extension.
817
+ let ruby = unsafe { Ruby::get_unchecked() };
818
+ Error::new(engine_panic_class(&ruby), message)
819
+ }
820
+
821
+ /// Run an exposed call with the panic net in front of magnus's.
822
+ fn guard<T>(f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
823
+ match panic::catch_unwind(AssertUnwindSafe(f)) {
824
+ Ok(result) => result,
825
+ Err(payload) => Err(engine_panic_error(payload)),
826
+ }
827
+ }
828
+
829
+ /// Declare the guarded wrapper Ruby is given for an implementation function.
830
+ ///
831
+ /// `=>` wraps an infallible implementation, `=>?` one that already returns
832
+ /// `Result`. Registering a bare implementation instead of its wrapper would
833
+ /// leave that one call raising `fatal` again, so `test/panic_unwind_test.rb`
834
+ /// reads the `function!` registrations below and fails on any name that is not
835
+ /// a `g_` wrapper.
836
+ macro_rules! guarded {
837
+ ($wrapper:ident ( $($arg:ident : $ty:ty),* ) -> $ret:ty => $inner:ident) => {
838
+ #[allow(clippy::too_many_arguments)]
839
+ fn $wrapper($($arg: $ty),*) -> Result<$ret, Error> {
840
+ guard(|| Ok($inner($($arg),*)))
841
+ }
842
+ };
843
+ ($wrapper:ident ( $($arg:ident : $ty:ty),* ) -> $ret:ty =>? $inner:ident) => {
844
+ #[allow(clippy::too_many_arguments)]
845
+ fn $wrapper($($arg: $ty),*) -> Result<$ret, Error> {
846
+ guard(|| $inner($($arg),*))
847
+ }
848
+ };
849
+ }
850
+
851
+ guarded!(g_to_html(source: String) -> String => to_html);
852
+ guarded!(g_to_markdown(source: String) -> String => to_markdown);
853
+ guarded!(g_to_plain_text(source: String) -> String => to_plain_text);
854
+ guarded!(g_to_ansi(source: String) -> String => to_ansi);
855
+ guarded!(g_to_carve(source: String) -> String => to_carve);
856
+ guarded!(g_from_markdown_json(source: String) -> String => from_markdown_json);
857
+ guarded!(g_to_ast_json(source: String) -> String => to_ast_json);
858
+ guarded!(g_extension_names() -> Vec<String> => extension_names);
859
+ guarded!(g_panic_probe() -> String => panic_probe);
860
+ guarded!(
861
+ g_stamp_needs_review(source: String, current_version: Option<String>) -> bool
862
+ => stamp_needs_review
863
+ );
864
+ guarded!(
865
+ g_from_html_json(ruby: &Ruby, source: String, mode: String) -> String =>? from_html_json
866
+ );
867
+ guarded!(g_read_stamp(ruby: &Ruby, source: String) -> Value =>? read_stamp);
868
+ guarded!(
869
+ g_to_html_with_extensions(ruby: &Ruby, source: String, names: RArray) -> String
870
+ =>? to_html_with_extensions
871
+ );
872
+ guarded!(
873
+ g_to_html_full(
874
+ ruby: &Ruby,
875
+ source: String,
876
+ names: RArray,
877
+ mode: String,
878
+ renderers: RHash
879
+ ) -> String =>? to_html_full
880
+ );
881
+ guarded!(
882
+ g_to_html_full_with_symbols(
883
+ ruby: &Ruby,
884
+ source: String,
885
+ names: RArray,
886
+ mode: String,
887
+ renderers: RHash,
888
+ symbols: RHash
889
+ ) -> String =>? to_html_full_with_symbols
890
+ );
891
+ guarded!(
892
+ g_to_html_safe(
893
+ ruby: &Ruby,
894
+ source: String,
895
+ names: RArray,
896
+ mode: String,
897
+ renderers: RHash,
898
+ symbols: RHash,
899
+ safe: bool,
900
+ profile: Option<String>,
901
+ sections: bool
902
+ ) -> String =>? to_html_safe
903
+ );
904
+ guarded!(
905
+ g_render_with_includes_json(
906
+ ruby: &Ruby,
907
+ source: String,
908
+ root: String,
909
+ source_path: String,
910
+ target: String,
911
+ names: RArray,
912
+ mode: String,
913
+ renderers: RHash,
914
+ symbols: RHash,
915
+ safe: bool,
916
+ profile: Option<String>,
917
+ sections: bool,
918
+ max_depth: Option<usize>,
919
+ max_bytes: Option<usize>,
920
+ max_resolver_calls: Option<usize>,
921
+ max_warnings: Option<usize>
922
+ ) -> String =>? render_with_includes_json
923
+ );
924
+
735
925
  /// Entry point invoked by Ruby when the extension is loaded.
736
926
  ///
737
927
  /// `name = "carve"` makes the macro emit the `Init_carve` symbol that matches
@@ -739,35 +929,43 @@ fn stamp_needs_review(source: String, current_version: Option<String>) -> bool {
739
929
  /// package is named `carve-rb`.
740
930
  #[magnus::init(name = "carve")]
741
931
  fn init(ruby: &Ruby) -> Result<(), Error> {
932
+ install_panic_hook();
933
+
742
934
  let module = ruby.define_module("Carve")?;
935
+ // Raised when the engine panics. A StandardError subclass on purpose:
936
+ // magnus would otherwise surface the panic as `fatal`, which no host can
937
+ // rescue, so an embedder taking untrusted input had no defense at all
938
+ // (markup-carve/carve-rb#170).
939
+ module.define_error("EnginePanic", ruby.exception_standard_error())?;
743
940
  // Native primitives. The pure-Ruby wrapper in lib/carve.rb defines the
744
941
  // public `Carve.to_html(source, extensions:, mode:, renderers:)` on top of
745
942
  // these. `_to_html` is the no-extension fast path; the wrapper owns the
746
943
  // bare `to_html` name.
747
- module.define_singleton_method("_to_html", function!(to_html, 1))?;
748
- module.define_singleton_method("to_markdown", function!(to_markdown, 1))?;
749
- module.define_singleton_method("to_plain_text", function!(to_plain_text, 1))?;
750
- module.define_singleton_method("to_ansi", function!(to_ansi, 1))?;
751
- module.define_singleton_method("to_carve", function!(to_carve, 1))?;
944
+ module.define_singleton_method("_to_html", function!(g_to_html, 1))?;
945
+ module.define_singleton_method("to_markdown", function!(g_to_markdown, 1))?;
946
+ module.define_singleton_method("to_plain_text", function!(g_to_plain_text, 1))?;
947
+ module.define_singleton_method("to_ansi", function!(g_to_ansi, 1))?;
948
+ module.define_singleton_method("to_carve", function!(g_to_carve, 1))?;
752
949
  module.define_singleton_method(
753
950
  "_render_with_includes_json",
754
- function!(render_with_includes_json, 15),
951
+ function!(g_render_with_includes_json, 15),
755
952
  )?;
756
- module.define_singleton_method("_from_html_json", function!(from_html_json, 2))?;
757
- module.define_singleton_method("_from_markdown_json", function!(from_markdown_json, 1))?;
758
- module.define_singleton_method("_to_ast_json", function!(to_ast_json, 1))?;
953
+ module.define_singleton_method("_from_html_json", function!(g_from_html_json, 2))?;
954
+ module.define_singleton_method("_from_markdown_json", function!(g_from_markdown_json, 1))?;
955
+ module.define_singleton_method("_to_ast_json", function!(g_to_ast_json, 1))?;
759
956
  module.define_singleton_method(
760
957
  "to_html_with_extensions",
761
- function!(to_html_with_extensions, 2),
958
+ function!(g_to_html_with_extensions, 2),
762
959
  )?;
763
- module.define_singleton_method("to_html_full", function!(to_html_full, 4))?;
960
+ module.define_singleton_method("to_html_full", function!(g_to_html_full, 4))?;
764
961
  module.define_singleton_method(
765
962
  "to_html_full_with_symbols",
766
- function!(to_html_full_with_symbols, 5),
963
+ function!(g_to_html_full_with_symbols, 5),
767
964
  )?;
768
- module.define_singleton_method("_to_html_safe", function!(to_html_safe, 8))?;
769
- module.define_singleton_method("_extension_names", function!(extension_names, 0))?;
770
- module.define_singleton_method("_read_stamp", function!(read_stamp, 1))?;
771
- module.define_singleton_method("_stamp_needs_review", function!(stamp_needs_review, 2))?;
965
+ module.define_singleton_method("_to_html_safe", function!(g_to_html_safe, 8))?;
966
+ module.define_singleton_method("_extension_names", function!(g_extension_names, 0))?;
967
+ module.define_singleton_method("_read_stamp", function!(g_read_stamp, 1))?;
968
+ module.define_singleton_method("_stamp_needs_review", function!(g_stamp_needs_review, 2))?;
969
+ module.define_singleton_method("_panic_probe", function!(g_panic_probe, 0))?;
772
970
  Ok(())
773
971
  }
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.6"
11
+ VERSION = "0.1.7"
12
12
  end
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.6
4
+ version: 0.1.7
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-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rb_sys