1use serde::{Deserialize, Serialize, Serializer};
50
51use crate::metric_set::{Metric, MetricSet};
52use crate::metrics::{
53 abc, cognitive, cyclomatic, halstead, loc, mi, nargs, nexits, nom, npa, npm, tokens, wmc,
54};
55use crate::spaces::SpaceKind;
56use crate::suppression::SuppressionScope;
57use crate::{function, ops};
58
59mod metrics;
65mod ops_view;
68#[cfg(feature = "vcs-git")]
71mod vcs;
72
73pub use metrics::*;
74#[cfg(feature = "vcs-git")]
75pub use vcs::*;
76
77fn nan_default() -> f64 {
81 f64::NAN
82}
83
84mod non_finite {
94 use serde::{Deserialize, Deserializer, Serializer};
95
96 #[allow(clippy::trivially_copy_pass_by_ref)]
99 pub(super) fn serialize<S: Serializer>(value: &f64, serializer: S) -> Result<S::Ok, S::Error> {
100 if value.is_finite() {
101 serializer.serialize_f64(*value)
102 } else {
103 serializer.serialize_none()
104 }
105 }
106
107 pub(super) fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<f64, D::Error> {
108 Ok(Option::<f64>::deserialize(deserializer)?.unwrap_or(f64::NAN))
109 }
110}
111
112#[cfg(feature = "vcs-git")]
116fn latest_present_risk(points: &[Option<crate::vcs::Stats>]) -> f64 {
117 points
118 .iter()
119 .rev()
120 .find_map(|s| s.as_ref().map(|s| s.risk_score))
121 .unwrap_or(0.0)
122}
123
124#[cfg(all(test, feature = "vcs-git"))]
125mod trend_wire_tests {
126 use super::*;
127
128 #[allow(clippy::float_cmp)]
131 fn risk(points: &[Option<f64>]) -> f64 {
132 let owned: Vec<Option<crate::vcs::Stats>> = points
133 .iter()
134 .map(|p| {
135 p.map(|risk_score| crate::vcs::Stats {
136 risk_score,
137 ..Default::default()
138 })
139 })
140 .collect();
141 latest_present_risk(&owned)
142 }
143
144 #[test]
145 #[allow(clippy::float_cmp)]
146 fn latest_present_risk_picks_the_newest_present_point() {
147 assert_eq!(risk(&[Some(1.0), None, Some(3.0), None]), 3.0);
150 }
151
152 #[test]
153 #[allow(clippy::float_cmp)]
154 fn latest_present_risk_defaults_to_zero_when_all_absent() {
155 assert_eq!(risk(&[None, None]), 0.0);
158 assert_eq!(risk(&[]), 0.0);
159 }
160
161 fn sample_vcs() -> Vcs {
163 Vcs {
164 commits_long: 12,
165 commits_recent: 4,
166 churn_long: 340,
167 churn_recent: 90,
168 authors_long: 3,
169 authors_recent: 2,
170 ownership_top_share: 0.625,
171 burst: 0.333,
172 bug_fix_commits: 2,
173 security_fix_commits: 1,
174 revert_commits: 0,
175 age_days: 200,
176 last_modified_days: 5,
177 change_entropy_long: 1.5,
178 change_entropy_recent: 0.5,
179 cochange_entropy_long: 2.0,
180 cochange_entropy_recent: 0.25,
181 risk_score: 7.5,
182 hotspot_score: Some(3.25),
183 author_ids: Some(vec!["deadbeef".to_owned()]),
184 }
185 }
186
187 #[test]
192 fn vcs_non_finite_floats_round_trip_as_null() {
193 let mut row = sample_vcs();
194 row.risk_score = f64::NAN;
195 row.burst = f64::INFINITY;
196 row.cochange_entropy_recent = f64::NEG_INFINITY;
197
198 let json = serde_json::to_string(&row).expect("serialize Vcs with NaN to JSON");
201 assert!(json.contains("\"risk_score\":null"), "got {json}");
202 let from_json: Vcs = serde_json::from_str(&json).expect("parse Vcs from JSON");
203 assert!(from_json.risk_score.is_nan());
204 assert!(from_json.burst.is_nan());
205 assert!(from_json.cochange_entropy_recent.is_nan());
206 assert_eq!(from_json.commits_long, row.commits_long);
208 assert!((from_json.ownership_top_share - row.ownership_top_share).abs() < 1e-12);
209
210 let yaml = serde_saphyr::to_string(&row).expect("serialize Vcs to YAML");
212 let from_yaml: Vcs = serde_saphyr::from_str(&yaml).expect("parse Vcs from YAML");
213 assert!(from_yaml.risk_score.is_nan() && from_yaml.burst.is_nan());
214
215 let mut bytes = Vec::new();
217 ciborium::into_writer(&row, &mut bytes).expect("serialize Vcs to CBOR");
218 let from_cbor: Vcs = ciborium::from_reader(bytes.as_slice()).expect("parse Vcs from CBOR");
219 assert!(from_cbor.risk_score.is_nan() && from_cbor.cochange_entropy_recent.is_nan());
220 }
221
222 #[test]
227 fn vcs_trend_point_round_trips_through_yaml_and_cbor() {
228 let point = VcsTrendPoint {
229 as_of: 1_700_000_000,
230 vcs: sample_vcs(),
231 };
232
233 let yaml = serde_saphyr::to_string(&point).expect("serialize VcsTrendPoint to YAML");
234 let from_yaml: VcsTrendPoint =
235 serde_saphyr::from_str(&yaml).expect("parse point from YAML");
236 assert_eq!(from_yaml, point);
237
238 let mut bytes = Vec::new();
239 ciborium::into_writer(&point, &mut bytes).expect("serialize VcsTrendPoint to CBOR");
240 let from_cbor: VcsTrendPoint =
241 ciborium::from_reader(bytes.as_slice()).expect("parse point from CBOR");
242 assert_eq!(from_cbor, point);
243 }
244
245 #[test]
246 fn vcs_trend_round_trips_through_yaml_and_cbor() {
247 let trend = VcsTrend {
248 trend_schema_version: 1,
249 vcs_schema_version: 2,
250 risk_score_version: 2,
251 long_window_days: 365,
252 recent_window_days: 90,
253 truncated_shallow_clone: false,
254 as_of_points: vec![1_699_000_000, 1_700_000_000],
255 files: std::collections::BTreeMap::from([(
256 "src/lib.rs".to_owned(),
257 vec![
258 None,
259 Some(VcsTrendPoint {
260 as_of: 1_700_000_000,
261 vcs: sample_vcs(),
262 }),
263 ],
264 )]),
265 deltas: VcsTrendDeltas::default(),
266 };
267
268 let yaml = serde_saphyr::to_string(&trend).expect("serialize VcsTrend to YAML");
269 let from_yaml: VcsTrend = serde_saphyr::from_str(&yaml).expect("parse VcsTrend from YAML");
270 assert_eq!(from_yaml, trend);
271
272 let mut bytes = Vec::new();
273 ciborium::into_writer(&trend, &mut bytes).expect("serialize VcsTrend to CBOR");
274 let from_cbor: VcsTrend =
275 ciborium::from_reader(bytes.as_slice()).expect("parse VcsTrend from CBOR");
276 assert_eq!(from_cbor, trend);
277 }
278}
279
280#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
290pub struct CodeMetrics {
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub nargs: Option<Nargs>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub nexits: Option<Nexits>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub cognitive: Option<Cognitive>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub cyclomatic: Option<Cyclomatic>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub halstead: Option<Halstead>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub loc: Option<Loc>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub nom: Option<Nom>,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub tokens: Option<Tokens>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub mi: Option<Mi>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub abc: Option<Abc>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
323 pub wmc: Option<Wmc>,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub npm: Option<Npm>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub npa: Option<Npa>,
330 #[cfg(feature = "vcs-git")]
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub vcs: Option<Vcs>,
335}
336
337impl From<&crate::spaces::CodeMetrics> for CodeMetrics {
338 fn from(c: &crate::spaces::CodeMetrics) -> Self {
339 let sel = c.selected;
340 let on = |m: Metric| sel.contains(m);
344 Self {
345 nargs: on(Metric::Nargs).then(|| Nargs::from(&c.nargs)),
346 nexits: on(Metric::Nexits).then(|| Nexits::from(&c.nexits)),
347 cognitive: on(Metric::Cognitive).then(|| Cognitive::from(&c.cognitive)),
348 cyclomatic: on(Metric::Cyclomatic).then(|| Cyclomatic::from(&c.cyclomatic)),
349 halstead: on(Metric::Halstead).then(|| Halstead::from(&c.halstead)),
350 loc: on(Metric::Loc).then(|| Loc::from(&c.loc)),
351 nom: on(Metric::Nom).then(|| Nom::from(&c.nom)),
352 tokens: on(Metric::Tokens).then(|| Tokens::from(&c.tokens)),
353 mi: on(Metric::Mi).then(|| Mi::from(&c.mi)),
354 abc: on(Metric::Abc).then(|| Abc::from(&c.abc)),
355 wmc: (on(Metric::Wmc) && !c.wmc.is_disabled()).then(|| Wmc::from(&c.wmc)),
356 npm: (on(Metric::Npm) && !c.npm.is_disabled()).then(|| Npm::from(&c.npm)),
357 npa: (on(Metric::Npa) && !c.npa.is_disabled()).then(|| Npa::from(&c.npa)),
358 #[cfg(feature = "vcs-git")]
361 vcs: c.vcs.as_ref().map(Vcs::from),
362 }
363 }
364}
365
366impl CodeMetrics {
367 #[must_use]
374 pub fn selected(&self) -> MetricSet {
375 let mut set = MetricSet::empty();
376 let mut mark = |present: bool, metric: Metric| {
377 if present {
378 set.insert(metric);
379 }
380 };
381 mark(self.nargs.is_some(), Metric::Nargs);
382 mark(self.nexits.is_some(), Metric::Nexits);
383 mark(self.cognitive.is_some(), Metric::Cognitive);
384 mark(self.cyclomatic.is_some(), Metric::Cyclomatic);
385 mark(self.halstead.is_some(), Metric::Halstead);
386 mark(self.loc.is_some(), Metric::Loc);
387 mark(self.nom.is_some(), Metric::Nom);
388 mark(self.tokens.is_some(), Metric::Tokens);
389 mark(self.mi.is_some(), Metric::Mi);
390 mark(self.abc.is_some(), Metric::Abc);
391 mark(self.wmc.is_some(), Metric::Wmc);
392 mark(self.npm.is_some(), Metric::Npm);
393 mark(self.npa.is_some(), Metric::Npa);
394 set
395 }
396}
397
398pub const MAX_SPACE_SERIALIZE_DEPTH: usize = 128;
415
416fn map_tree<'a, Src, Dst>(
426 root: &'a Src,
427 children_of: fn(&'a Src) -> &'a [Src],
428 build: fn(&'a Src, Vec<Dst>) -> Dst,
429) -> Dst {
430 let mut root_frame = MapFrame::new(root, children_of);
434 let mut descendants = Vec::new();
435 loop {
436 let frame = match descendants.last_mut() {
437 Some(frame) => frame,
438 None => &mut root_frame,
439 };
440 let source = frame.source;
441 if let Some(child) = children_of(source).get(frame.next_child) {
442 frame.next_child += 1;
443 descendants.push(MapFrame::new(child, children_of));
444 continue;
445 }
446 let Some(done) = descendants.pop() else { break };
449 let converted = build(done.source, done.children);
450 match descendants.last_mut() {
451 Some(parent) => parent.children.push(converted),
452 None => root_frame.children.push(converted),
453 }
454 }
455 build(root_frame.source, root_frame.children)
456}
457
458struct MapFrame<'a, Src, Dst> {
460 source: &'a Src,
462 next_child: usize,
464 children: Vec<Dst>,
466}
467
468impl<'a, Src, Dst> MapFrame<'a, Src, Dst> {
469 fn new(source: &'a Src, children_of: fn(&'a Src) -> &'a [Src]) -> Self {
470 Self {
471 source,
472 next_child: 0,
473 children: Vec::with_capacity(children_of(source).len()),
474 }
475 }
476}
477
478fn serialize_spaces<S: Serializer>(spaces: &[FuncSpace], serializer: S) -> Result<S::Ok, S::Error> {
481 crate::recursion::serialize_bounded(spaces, MAX_SPACE_SERIALIZE_DEPTH, "FuncSpace", serializer)
482}
483
484fn serialize_ops_spaces<S: Serializer>(spaces: &[Ops], serializer: S) -> Result<S::Ok, S::Error> {
487 crate::recursion::serialize_bounded(spaces, MAX_SPACE_SERIALIZE_DEPTH, "Ops", serializer)
488}
489
490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
492pub struct FuncSpace {
493 pub name: Option<String>,
495 pub start_line: usize,
497 pub end_line: usize,
499 pub kind: SpaceKind,
501 #[serde(serialize_with = "serialize_spaces")]
503 pub spaces: Vec<FuncSpace>,
504 pub metrics: CodeMetrics,
506 #[serde(default, skip_serializing_if = "SuppressionScope::is_empty")]
509 pub suppressed: SuppressionScope,
510}
511
512crate::recursion::impl_iterative_drop!(FuncSpace, spaces);
515
516impl From<&crate::spaces::FuncSpace> for FuncSpace {
517 fn from(f: &crate::spaces::FuncSpace) -> Self {
518 map_tree(
519 f,
520 |source| &source.spaces,
521 |source, spaces| Self {
522 name: source.name.clone(),
523 start_line: source.start_line,
524 end_line: source.end_line,
525 kind: source.kind,
526 spaces,
527 metrics: CodeMetrics::from(&source.metrics),
528 suppressed: source.suppressed.clone(),
529 },
530 )
531 }
532}
533
534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
536pub struct Ops {
537 pub name: Option<String>,
539 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
541 pub name_was_lossy: bool,
542 pub start_line: usize,
544 pub end_line: usize,
546 pub kind: SpaceKind,
548 #[serde(serialize_with = "serialize_ops_spaces")]
550 pub spaces: Vec<Ops>,
551 pub operands: Vec<String>,
553 pub operators: Vec<String>,
555}
556
557crate::recursion::impl_iterative_drop!(Ops, spaces);
560
561crate::observation::counter!(owned_ops_projections);
568
569impl From<&ops::Ops> for Ops {
570 fn from(o: &ops::Ops) -> Self {
571 owned_ops_projections::record();
572 map_tree(
573 o,
574 |source| &source.spaces,
575 |source, spaces| Self {
576 name: source.name.clone(),
577 name_was_lossy: source.name_was_lossy,
578 start_line: source.start_line,
579 end_line: source.end_line,
580 kind: source.kind,
581 spaces,
582 operands: source.operands.clone(),
583 operators: source.operators.clone(),
584 },
585 )
586 }
587}
588
589#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
591pub struct FunctionSpan {
592 pub name: Option<String>,
594 pub start_line: usize,
596 pub end_line: usize,
598}
599
600impl From<&function::FunctionSpan> for FunctionSpan {
601 fn from(f: &function::FunctionSpan) -> Self {
602 Self {
603 name: f.name.clone(),
604 start_line: f.start_line,
605 end_line: f.end_line,
606 }
607 }
608}
609
610macro_rules! serialize_via_wire {
620 ($compute:ty => $wire:ident) => {
621 impl Serialize for $compute {
622 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
623 $wire::from(self).serialize(serializer)
624 }
625 }
626 };
627}
628
629serialize_via_wire!(abc::Stats => Abc);
630serialize_via_wire!(cognitive::Stats => Cognitive);
631serialize_via_wire!(cyclomatic::Stats => Cyclomatic);
632serialize_via_wire!(nexits::Stats => Nexits);
633serialize_via_wire!(halstead::Stats => Halstead);
634serialize_via_wire!(loc::Stats => Loc);
635serialize_via_wire!(mi::Stats => Mi);
636serialize_via_wire!(nargs::Stats => Nargs);
637serialize_via_wire!(nom::Stats => Nom);
638serialize_via_wire!(npa::Stats => Npa);
639serialize_via_wire!(npm::Stats => Npm);
640serialize_via_wire!(tokens::Stats => Tokens);
641serialize_via_wire!(wmc::Stats => Wmc);
642serialize_via_wire!(crate::spaces::CodeMetrics => CodeMetrics);
643serialize_via_wire!(crate::spaces::FuncSpace => FuncSpace);
644serialize_via_wire!(function::FunctionSpan => FunctionSpan);
647
648#[cfg(test)]
651#[path = "wire_ops_tests.rs"]
652mod ops_tests;
653
654#[cfg(test)]
655#[allow(clippy::float_cmp)]
661mod tests {
662 use super::*;
663 use crate::RustParser;
664 use crate::test_support::check_func_space;
665
666 #[cfg(feature = "rust")]
669 const FIXTURE: &str = "\
670fn classify(x: i32) -> i32 {
671 if x > 0 {
672 x * 2
673 } else if x < 0 {
674 -x
675 } else {
676 0
677 }
678}
679
680fn run() {
681 let adder = |a: i32, b: i32| a + b;
682 let _ = adder(classify(3), classify(-4));
683}
684";
685
686 #[cfg(feature = "rust")]
693 fn assert_fixture_oracle(tree: &FuncSpace) {
694 assert_eq!(tree.kind, SpaceKind::Unit);
696 assert_eq!(tree.spaces.len(), 2, "classify + run");
697
698 let m = &tree.metrics;
699 assert_eq!(m.cyclomatic.as_ref().unwrap().sum, 6, "unit cyclomatic.sum");
700 assert_eq!(
704 m.cyclomatic.as_ref().unwrap().value,
705 1,
706 "unit cyclomatic.value (own, excludes children)"
707 );
708 assert_eq!(m.cognitive.as_ref().unwrap().sum, 3, "unit cognitive.sum");
709 assert_eq!(
710 m.cognitive.as_ref().unwrap().value,
711 0,
712 "unit cognitive.value (own)"
713 );
714 assert_eq!(m.loc.as_ref().unwrap().sloc, 14, "unit loc.sloc");
715 assert_eq!(m.nom.as_ref().unwrap().total, 3, "unit nom.total");
716 let abc = m.abc.as_ref().unwrap();
719 assert_eq!((abc.assignments, abc.branches, abc.conditions), (2, 3, 4));
720
721 let classify = tree
722 .spaces
723 .iter()
724 .find(|s| s.name.as_deref() == Some("classify"))
725 .expect("classify space");
726 let classify_cyclo = classify.metrics.cyclomatic.as_ref().unwrap();
727 assert_eq!(classify_cyclo.sum, 3, "classify cyclomatic.sum");
728 assert_eq!(classify_cyclo.value, 3, "classify cyclomatic.value (leaf)");
730
731 let run = tree
735 .spaces
736 .iter()
737 .find(|s| s.name.as_deref() == Some("run"))
738 .expect("run space");
739 let run_cyclo = run.metrics.cyclomatic.as_ref().unwrap();
740 assert_eq!(run_cyclo.sum, 2, "run cyclomatic.sum (run + adder closure)");
741 assert_eq!(
742 run_cyclo.value, 1,
743 "run cyclomatic.value (own, excludes closure)"
744 );
745 }
746
747 #[cfg(feature = "rust")]
752 #[test]
753 fn json_round_trips() {
754 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
755 let json = serde_json::to_string(&fs).expect("serialize FuncSpace to JSON");
756 let back: FuncSpace = serde_json::from_str(&json).expect("parse wire::FuncSpace");
757 assert_eq!(
758 back,
759 fs.to_wire(),
760 "deserialized wire tree must equal the projection"
761 );
762 assert_eq!(
763 serde_json::to_string(&back).expect("re-serialize wire"),
764 json,
765 "re-serialized wire must be byte-identical to the original JSON",
766 );
767 assert_fixture_oracle(&back);
770 });
771 }
772
773 #[test]
777 fn loc_document_without_dloc_fields_still_parses() {
778 let json = r#"{"sloc": 4, "ploc": 2, "lloc": 1, "cloc": 1, "blank": 1,
779 "sloc_average": 2.0, "ploc_average": 1.0, "lloc_average": 0.5,
780 "cloc_average": 0.5, "blank_average": 0.5,
781 "sloc_min": 2, "sloc_max": 4, "cloc_min": 0, "cloc_max": 1,
782 "ploc_min": 2, "ploc_max": 2, "lloc_min": 1, "lloc_max": 1,
783 "blank_min": 0, "blank_max": 1}"#;
784 let loc: Loc = serde_json::from_str(json).expect("pre-#1597 loc block parses");
785 assert_eq!(
786 (loc.dloc, loc.dloc_min, loc.dloc_max),
787 (0, 0, 0),
788 "absent integer dloc fields default to 0"
789 );
790 assert!(
791 loc.dloc_average.is_nan(),
792 "an absent average defaults to NaN, as the sibling averages do"
793 );
794 assert_eq!((loc.sloc, loc.cloc, loc.blank_max), (4, 1, 1));
796 }
797
798 #[cfg(feature = "rust")]
799 #[test]
800 fn yaml_round_trips() {
801 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
802 let yaml = serde_saphyr::to_string(&fs).expect("serialize to YAML");
803 let back: FuncSpace = serde_saphyr::from_str(&yaml).expect("parse wire from YAML");
804 assert_eq!(back, fs.to_wire());
805 assert_eq!(serde_saphyr::to_string(&back).expect("re-serialize"), yaml);
806 });
807 }
808
809 #[cfg(feature = "rust")]
810 #[test]
811 fn toml_round_trips() {
812 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
813 let toml = toml::to_string(&fs).expect("serialize to TOML");
814 let back: FuncSpace = toml::from_str(&toml).expect("parse wire from TOML");
815 assert_eq!(back, fs.to_wire());
816 assert_eq!(toml::to_string(&back).expect("re-serialize"), toml);
817 });
818 }
819
820 #[cfg(feature = "rust")]
821 #[test]
822 fn cbor_round_trips() {
823 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
824 let mut bytes = Vec::new();
825 ciborium::into_writer(&fs, &mut bytes).expect("serialize to CBOR");
826 let back: FuncSpace =
827 ciborium::from_reader(bytes.as_slice()).expect("parse wire from CBOR");
828 assert_eq!(back, fs.to_wire());
829 let mut re = Vec::new();
830 ciborium::into_writer(&back, &mut re).expect("re-serialize");
831 assert_eq!(re, bytes, "CBOR re-serialization must be byte-identical");
832 });
833 }
834
835 #[test]
840 fn function_span_round_trips() {
841 let resolved = FunctionSpan {
842 name: Some("foo".to_owned()),
843 start_line: 1,
844 end_line: 4,
845 };
846 let unresolved = FunctionSpan {
847 name: None,
848 start_line: 7,
849 end_line: 8,
850 };
851
852 for span in [resolved, unresolved] {
853 let json = serde_json::to_string(&span).expect("serialize FunctionSpan");
854 assert!(
855 !json.contains("error"),
856 "FunctionSpan JSON must not carry an `error` key, got {json}",
857 );
858 let back: FunctionSpan = serde_json::from_str(&json).expect("parse FunctionSpan");
859 assert_eq!(back, span, "FunctionSpan must round-trip through JSON");
860 }
861
862 let json = serde_json::to_string(&FunctionSpan {
864 name: None,
865 start_line: 7,
866 end_line: 8,
867 })
868 .expect("serialize");
869 assert!(
870 json.contains(r#""name":null"#),
871 "unresolved name must serialize to JSON null, got {json}",
872 );
873 }
874
875 #[test]
880 fn non_finite_floats_round_trip_as_null_or_omission() {
881 for probe in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
882 let mi = Mi {
883 original: probe,
884 sei: 1.5,
885 visual_studio: 2.0,
886 };
887
888 let json = serde_json::to_string(&mi).expect("JSON");
889 assert!(
890 json.contains(r#""original":null"#),
891 "non-finite must serialize to JSON null, got {json}",
892 );
893 assert!(
894 serde_json::from_str::<Mi>(&json)
895 .expect("parse")
896 .original
897 .is_nan(),
898 "JSON null must deserialize back to NaN",
899 );
900
901 let yaml = serde_saphyr::to_string(&mi).expect("YAML");
902 assert!(
903 yaml.contains("original: null"),
904 "non-finite must serialize to YAML null, got {yaml}",
905 );
906 assert!(
907 serde_saphyr::from_str::<Mi>(&yaml)
908 .expect("parse")
909 .original
910 .is_nan()
911 );
912
913 let toml = toml::to_string(&mi).expect("TOML");
914 assert!(
915 !toml.contains("original"),
916 "TOML must omit the non-finite key (no null literal), got {toml}",
917 );
918 assert!(
919 toml::from_str::<Mi>(&toml)
920 .expect("parse")
921 .original
922 .is_nan(),
923 "omitted TOML key must default back to NaN",
924 );
925
926 let mut cbor = Vec::new();
929 ciborium::into_writer(&mi, &mut cbor).expect("CBOR");
930 let value: ciborium::value::Value =
931 ciborium::from_reader(cbor.as_slice()).expect("parse cbor value");
932 let ciborium::value::Value::Map(map) = &value else {
933 panic!("CBOR root is not a map");
934 };
935 let original_key = ciborium::value::Value::Text("original".to_owned());
936 let original = map
937 .iter()
938 .find_map(|(k, v)| (*k == original_key).then_some(v));
939 assert_eq!(
940 original,
941 Some(&ciborium::value::Value::Null),
942 "non-finite must serialize to CBOR null",
943 );
944 assert!(
945 ciborium::from_reader::<Mi, _>(cbor.as_slice())
946 .expect("parse")
947 .original
948 .is_nan(),
949 "CBOR null must deserialize back to NaN",
950 );
951
952 let back = serde_json::from_str::<Mi>(&json).expect("parse");
954 assert_eq!(back.sei, 1.5);
955 assert_eq!(back.visual_studio, 2.0);
956 }
957 }
958
959 #[cfg(feature = "rust")]
963 #[test]
964 fn selected_is_inferred_from_present_keys() {
965 check_func_space::<RustParser, _>(FIXTURE, "fixture.rs", |fs| {
966 let full = fs.metrics.to_wire();
967 let selected = full.selected();
968 assert!(selected.contains(Metric::Loc));
969 assert!(selected.contains(Metric::Cyclomatic));
970
971 let json = serde_json::to_string(&full).expect("serialize metrics");
973 let mut value: serde_json::Value = serde_json::from_str(&json).expect("parse value");
974 let obj = value.as_object_mut().expect("metrics object");
975 obj.retain(|k, _| k == "loc");
976 let pruned: CodeMetrics =
977 serde_json::from_value(value).expect("parse pruned wire metrics");
978 let pruned_selected = pruned.selected();
979 assert!(pruned_selected.contains(Metric::Loc));
980 assert!(!pruned_selected.contains(Metric::Cyclomatic));
981 assert!(pruned.cyclomatic.is_none());
982 });
983 }
984
985 #[cfg(feature = "rust")]
999 const PRODUCTION_STACK: usize = 2 * 1024 * 1024;
1000
1001 const TIGHT_STACK: usize = 512 * 1024;
1005
1006 pub(super) fn nested_functions(depth: usize) -> String {
1009 use std::fmt::Write as _;
1010 let mut source = String::with_capacity(depth * 14);
1011 for level in 0..depth {
1012 let _ = writeln!(source, "fn f{level}() {{");
1013 }
1014 for _ in 0..depth {
1015 source.push_str("}\n");
1016 }
1017 source
1018 }
1019
1020 #[cfg(feature = "rust")]
1023 fn analyze_nested(depth: usize) -> crate::FuncSpace {
1024 crate::analyze(
1025 crate::Source::new(crate::LANG::Rust, nested_functions(depth).as_bytes())
1026 .with_name(Some("nested.rs".to_owned())),
1027 crate::MetricsOptions::default().with_only(&[Metric::Loc]),
1028 )
1029 .expect("nested-function fixture must analyse")
1030 }
1031
1032 fn wire_nesting_depth(space: &FuncSpace) -> usize {
1036 let mut deepest = 0;
1037 let mut stack = vec![(space, 1_usize)];
1038 while let Some((node, depth)) = stack.pop() {
1039 deepest = deepest.max(depth);
1040 for child in &node.spaces {
1041 stack.push((child, depth + 1));
1042 }
1043 }
1044 deepest
1045 }
1046
1047 fn on_stack<T: Send + 'static>(bytes: usize, body: impl FnOnce() -> T + Send + 'static) -> T {
1050 std::thread::Builder::new()
1051 .stack_size(bytes)
1052 .spawn(body)
1053 .expect("spawn bounded-stack thread")
1054 .join()
1055 .expect("bounded-stack thread must not overflow")
1056 }
1057
1058 fn space_chain(depth: usize) -> crate::FuncSpace {
1066 let leaf = || crate::FuncSpace {
1067 name: Some("f".to_owned()),
1068 start_line: 1,
1069 end_line: 1,
1070 kind: SpaceKind::Function,
1071 spaces: Vec::new(),
1072 metrics: crate::CodeMetrics::default(),
1073 suppressed: SuppressionScope::default(),
1074 };
1075 let mut root = leaf();
1076 let mut cursor = &mut root;
1077 for _ in 0..depth {
1078 cursor.spaces.push(leaf());
1079 cursor = cursor.spaces.last_mut().expect("just pushed");
1080 }
1081 root
1082 }
1083
1084 #[cfg(feature = "rust")]
1085 #[test]
1086 fn deeply_nested_spaces_convert_to_wire_without_stack_overflow() {
1087 const DEPTH: usize = 2_000;
1093 let depth = on_stack(TIGHT_STACK, || {
1094 let space = analyze_nested(DEPTH);
1095 wire_nesting_depth(&space.to_wire())
1096 });
1097 assert_eq!(depth, DEPTH + 1, "the whole chain must survive conversion");
1099 }
1100
1101 #[test]
1102 fn a_pathologically_deep_space_chain_converts_and_tears_down() {
1103 const DEPTH: usize = 100_000;
1108 let depth = on_stack(TIGHT_STACK, || {
1109 let space = space_chain(DEPTH);
1110 wire_nesting_depth(&space.to_wire())
1111 });
1112 assert_eq!(depth, DEPTH + 1, "the whole chain must survive conversion");
1113 }
1114
1115 #[cfg(feature = "rust")]
1116 #[test]
1117 fn spaces_deeper_than_the_limit_fail_serialization_rather_than_abort() {
1118 const DEPTH: usize = 2_000;
1123 let message = on_stack(PRODUCTION_STACK, || {
1124 let space = analyze_nested(DEPTH);
1125 serde_json::to_string(&space)
1126 .expect_err("nesting past the limit must fail, not serialize")
1127 .to_string()
1128 });
1129 assert!(
1130 message.contains("FuncSpace nesting is deeper than the serialization limit of 128"),
1131 "the error must name the type and the limit, got: {message}"
1132 );
1133 }
1134
1135 #[cfg(feature = "rust")]
1136 #[test]
1137 fn space_nesting_at_the_serialize_limit_is_accepted_and_one_deeper_is_not() {
1138 let (accepted, rejected) = on_stack(PRODUCTION_STACK, || {
1142 let at_limit = analyze_nested(MAX_SPACE_SERIALIZE_DEPTH);
1143 let past_limit = analyze_nested(MAX_SPACE_SERIALIZE_DEPTH + 1);
1144 (
1145 [
1146 serde_json::to_string(&at_limit).is_ok(),
1147 serde_saphyr::to_string(&at_limit).is_ok(),
1148 toml::to_string(&at_limit).is_ok(),
1149 {
1150 let mut bytes = Vec::new();
1151 ciborium::into_writer(&at_limit, &mut bytes).is_ok()
1152 },
1153 ],
1154 [
1155 serde_json::to_string(&past_limit).is_ok(),
1156 serde_saphyr::to_string(&past_limit).is_ok(),
1157 toml::to_string(&past_limit).is_ok(),
1158 {
1159 let mut bytes = Vec::new();
1160 ciborium::into_writer(&past_limit, &mut bytes).is_ok()
1161 },
1162 ],
1163 )
1164 });
1165 assert_eq!(
1166 accepted, [true; 4],
1167 "exactly {MAX_SPACE_SERIALIZE_DEPTH} levels must serialize in every format"
1168 );
1169 assert_eq!(
1170 rejected, [false; 4],
1171 "one level past the limit must be refused in every format"
1172 );
1173 }
1174
1175 #[cfg(feature = "rust")]
1176 #[test]
1177 fn deeply_nested_ops_convert_and_serialize_without_stack_overflow() {
1178 const DEPTH: usize = 2_000;
1181 let (converted_depth, message) = on_stack(PRODUCTION_STACK, || {
1182 let ops = crate::Ast::parse(crate::Source::new(
1183 crate::LANG::Rust,
1184 nested_functions(DEPTH).as_bytes(),
1185 ))
1186 .expect("nested-function fixture must parse")
1187 .ops()
1188 .expect("nested-function fixture must yield ops");
1189 let wire = Ops::from(&ops);
1190 let mut deepest = 0;
1191 let mut stack = vec![(&wire, 1_usize)];
1192 while let Some((node, depth)) = stack.pop() {
1193 deepest = deepest.max(depth);
1194 for child in &node.spaces {
1195 stack.push((child, depth + 1));
1196 }
1197 }
1198 let message = serde_json::to_string(&ops)
1199 .expect_err("nesting past the limit must fail, not serialize")
1200 .to_string();
1201 (deepest, message)
1202 });
1203 assert_eq!(converted_depth, DEPTH + 1, "the whole chain must convert");
1204 assert!(
1205 message.contains("Ops nesting is deeper than the serialization limit of 128"),
1206 "the error must name the type and the limit, got: {message}"
1207 );
1208 }
1209}