differential_engine/lang/mod.rs
1//! Language abstraction (ADR 0015).
2//!
3//! The tool must eventually support every language. This module is the seam:
4//! a [`Language`] plugin can override how line content is normalised for shape
5//! classification. Everything defaults to the generic behaviour, so with no
6//! plugins registered the engine behaves exactly like the validated milestone-1
7//! normaliser (the parity test enforces this).
8//!
9//! **Normalisation only.** Symbol extraction used to hang off this trait too.
10//! It is a different use case with different consumers, so it has its own port
11//! (`artefact::symbols`) — one trait serving two unrelated needs is the merged
12//! supertrait `CLAUDE.md` rule 4 forbids.
13//!
14//! Languages never see enumeration: which files and hunks exist is decided
15//! before this module is consulted (ADR 0005/0012). They only influence
16//! *classification*.
17
18pub mod generic;
19
20/// A language plugin. Every method has a working generic default, so a new
21/// language implements only what it improves on.
22pub trait Language: Send + Sync {
23 /// Stable identifier, e.g. "generic", "rust". Part of the registry
24 /// fingerprint, so bump-worthy behaviour changes need a new id or version.
25 fn id(&self) -> &'static str;
26
27 /// Whether this plugin claims the file (typically by extension/basename).
28 /// The registry's generic fallback claims everything.
29 fn claims(&self, path: &[u8]) -> bool;
30
31 /// Normalise one line's content for shape classification: erase what varies
32 /// between instances of the same edit (identifiers, literals, spacing),
33 /// keep what distinguishes different edits.
34 ///
35 /// This feeds the shape hash ONLY — never `hunk_digest`, which is the
36 /// exact-content persistence anchor.
37 fn normalize_line(&self, line: &[u8]) -> Vec<u8> {
38 generic::normalize_line(line)
39 }
40}
41
42/// The generic fallback: claims every file, uses the default normaliser.
43pub struct Generic;
44
45impl Language for Generic {
46 fn id(&self) -> &'static str {
47 "generic-v1"
48 }
49 fn claims(&self, _path: &[u8]) -> bool {
50 true
51 }
52}
53
54/// Ordered set of language plugins with a guaranteed generic fallback.
55pub struct LanguageRegistry {
56 langs: Vec<Box<dyn Language>>,
57 fallback: Generic,
58}
59
60impl Default for LanguageRegistry {
61 fn default() -> Self {
62 Self::builtin()
63 }
64}
65
66impl LanguageRegistry {
67 /// The built-in registry: generic fallback only (for now).
68 pub fn builtin() -> Self {
69 LanguageRegistry {
70 langs: Vec::new(),
71 fallback: Generic,
72 }
73 }
74
75 /// Register a plugin. First registered, first asked; the generic fallback
76 /// always answers last.
77 pub fn register(&mut self, lang: Box<dyn Language>) {
78 self.langs.push(lang);
79 }
80
81 pub fn detect(&self, path: &[u8]) -> &dyn Language {
82 for l in &self.langs {
83 if l.claims(path) {
84 return l.as_ref();
85 }
86 }
87 &self.fallback
88 }
89
90 /// Cache-key component: shape hashes depend on normalisation, so anything
91 /// pinned to a partition (e.g. the future grouping cache) must include this.
92 /// Two registries with the same fingerprint classify identically.
93 pub fn fingerprint(&self) -> String {
94 let mut parts: Vec<&str> = self.langs.iter().map(|l| l.id()).collect();
95 parts.push(self.fallback.id());
96 parts.join("+")
97 }
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 struct FakeToml;
105 impl Language for FakeToml {
106 fn id(&self) -> &'static str {
107 "fake-toml-v1"
108 }
109 fn claims(&self, path: &[u8]) -> bool {
110 path.ends_with(b".toml")
111 }
112 fn normalize_line(&self, _line: &[u8]) -> Vec<u8> {
113 b"T".to_vec()
114 }
115 }
116
117 #[test]
118 fn fallback_claims_everything() {
119 let reg = LanguageRegistry::builtin();
120 assert_eq!(reg.detect(b"whatever.xyz").id(), "generic-v1");
121 assert_eq!(reg.fingerprint(), "generic-v1");
122 }
123
124 #[test]
125 fn registered_language_wins_for_its_files_only() {
126 let mut reg = LanguageRegistry::builtin();
127 reg.register(Box::new(FakeToml));
128 assert_eq!(reg.detect(b"Cargo.toml").id(), "fake-toml-v1");
129 assert_eq!(reg.detect(b"src/main.rs").id(), "generic-v1");
130 assert_eq!(reg.fingerprint(), "fake-toml-v1+generic-v1");
131 }
132}