diff --git a/.changeset/rust-language-extractor.md b/.changeset/rust-language-extractor.md new file mode 100644 index 0000000..52e1267 --- /dev/null +++ b/.changeset/rust-language-extractor.md @@ -0,0 +1,5 @@ +--- +'@razakadam74/gitmsg': minor +--- + +Add Rust language extractor for symbol-aware subject lines on `.rs` files. Recognises top-level `fn`, `struct`, `enum`, `trait`, `type` aliases, and `const` declarations — including generics, `async`/`unsafe`/`const`/`extern "C"` function qualifiers, and `unsafe` auto traits. Visibility comes from the `pub` line prefix, so `pub(crate)`/`pub(super)` read as non-exported. Methods (indented inside `impl`/`trait` blocks), inline-module items, `static`, `union`, and `macro_rules!` are out of scope; see [`docs/languages/rust.md`](https://github.com/razakadam74/gitmsg/blob/main/docs/languages/rust.md) for the full pattern ladder and blind spots. diff --git a/README.md b/README.md index 8db5466..a3cc4b3 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Symbol-aware subject lines (e.g. `feat(auth): add rotateRefreshToken`) are suppo | C# | `.cs`, `.csx` | | Go | `.go` | | Java | `.java` | +| Rust | `.rs` | Files in other languages still get a sensible commit message — the subject just falls back to a generic verb (e.g. `docs: update README`, `feat(parser): refactor module`) instead of naming specific symbols. Adding a language is a small, well-scoped contribution; see the recipe in [`docs/heuristics.md`](docs/heuristics.md#adding-a-new-language). @@ -98,7 +99,7 @@ Edit the message — `gitmsg --edit` opens your editor pre-filled. If a particul ## Status -🚧 Early development. Published as [`@razakadam74/gitmsg`](https://www.npmjs.com/package/@razakadam74/gitmsg) with TypeScript/JavaScript, Python, C#, Go, and Java extractors. APIs and behaviour may still change before v1.0. +🚧 Early development. Published as [`@razakadam74/gitmsg`](https://www.npmjs.com/package/@razakadam74/gitmsg) with TypeScript/JavaScript, Python, C#, Go, Java, and Rust extractors. APIs and behaviour may still change before v1.0. ## Contributing diff --git a/docs/languages/rust.md b/docs/languages/rust.md new file mode 100644 index 0000000..515c5e6 --- /dev/null +++ b/docs/languages/rust.md @@ -0,0 +1,120 @@ +# Rust + +Symbol extraction for `.rs` files. + +Source: [`src/languages/rust.ts`](../../src/languages/rust.ts). + +## Pattern ladder + +Six regex patterns. First match per line wins. All patterns are anchored at `^` +(column 0, no leading whitespace) — see the anchor decision below. + +| # | Pattern shape | Kind | +| --- | ----------------- | ----------- | +| 1 | `… fn NAME(…)` | `function` | +| 2 | `… struct NAME` | `class` | +| 3 | `… enum NAME` | `type` | +| 4 | `… trait NAME` | `interface` | +| 5 | `… type NAME …` | `type` | +| 6 | `… const NAME: …` | `const` | + +Every row starts with the same optional visibility prefix +`(?:pub(?:\([^)]*\))?\s+)?` — it eats `pub` or a restricted form like +`pub(crate)` / `pub(super)` / `pub(in path)` before the keyword. Visibility +itself is computed separately (see below); the prefix only exists so the keyword +and name still line up when a `pub` is present. + +The `fn` row carries two extra eaters: `(?:(?:async|unsafe|const)\s+)*` for +function qualifiers and `(?:extern\s+"[^"]*"\s+)?` for `extern "C"`-style ABI +markers. The `trait` row eats an optional leading `unsafe` (auto traits like +`unsafe trait Send`). + +Row 1 (`fn`) **must** precede row 6 (`const`): a `const fn` is a function, not a +const. Two things pin this — `fn` is listed first, and the `const` row requires a +trailing `:` (`const NAME:`), which a `const fn foo()` declaration never has. So +even if the order were swapped, `const fn` would still fall through to the `fn` +row. + +Generic type parameters (``, ``, ``) are not +captured — the name capture stops at the bare identifier, and the angle-bracket +list is part of the declaration, not the name. + +### The anchor (the first decision) + +> 💡 **Decision:** All rows anchor at `^` (column 0), like Go — **not** `^\s*` +> like C#/Java. Rust methods live inside indented `impl` and `trait` blocks and +> are syntactically identical to free functions (`fn name(...)`). A column-0 +> anchor is the only single-line way to tell a free function from a method: free +> items sit at column 0, members are indented. The cost is that items declared +> inside an inline `mod foo { … }` block (also indented) are missed — accepted, +> because file-based modules (`mod foo;` + `foo.rs`) are the norm. + +### Visibility (the headline decision) + +> 💡 **Decision:** Visibility comes from the **line prefix**: +> `exported = /^pub\s/.test(line)`. Only a bare `pub` followed by whitespace +> exports. `pub(crate)`, `pub(super)`, and `pub(in path)` all read as +> `exported: false` — after `pub` comes a `(`, not whitespace, so the test +> fails. Items with no `pub` are private and also read as `exported: false`. + +This is the sixth visibility model in the codebase: + +| Extractor | Source | Computation | +| --------- | --------------- | ------------------------------- | +| TS | regex row | `export` literal in the pattern | +| Python | name | `!name.startsWith('_')` | +| C# | line prefix | `/^\s*public\s/.test(line)` | +| Go | name | `/^[A-Z]/.test(name)` | +| Java | line prefix | `/^\s*public\s+/.test(line)` | +| **Rust** | **line prefix** | **`/^pub\s/.test(line)`** | + +Rust is a hybrid: it shares the **line-prefix** visibility model with C#/Java, +but the **column-0 anchor** with Go. The line prefix carries no `\s*` because the +column-0 anchor already forbids leading whitespace — restricted visibility +(`pub(crate)`) is excluded by the absence of a space after `pub`, not by the +anchor. + +## Dedup key + +Per-file extraction uses `${kind}:${name}:${exported}`. Same shape as every other +extractor — see the [heuristics overview](../heuristics.md#the-dedup-key-distinction) +for the cross-file vs per-file rationale. + +## v1 blind spots + +Documented limitations, accepted as tradeoffs against the "single-line regex, no +parser" design: + +- **Methods.** `fn` declarations inside `impl`/`trait` blocks are indented, so the + column-0 anchor skips them. A struct's inherent methods (`impl Foo { pub fn new() }`) + are not extracted; only the `struct Foo` line is. Same philosophy as Java — + naming the type is honest; over-claiming every method is not. +- **Inline-module items.** Items inside `mod foo { … }` are indented and therefore + missed. File-based modules are unaffected (their items are at column 0). +- **`static` items.** `static MAX: u32 = …` is not extracted (no row). Globals are + rarer than `const` in public APIs; add a row if a real codebase needs it. +- **`union` and `macro_rules!`.** Unions (FFI-only, rare) and declarative macros + have no row. +- **`pub(crate) fn` parameter capture.** The shared callable helper scans the line + for the first `(…)`. For `pub(crate) fn foo(a: i32)` that first paren is the + visibility `(crate)`, so `params` becomes `"crate"` instead of `"a: i32"`. The + symbol is non-exported anyway, so this only affects signature-change detection of + crate-private functions — accepted. Plain `pub fn` and private `fn` capture params + correctly. +- **Block comments masking declarations.** `/* pub fn foo() {` still matches the + `fn` row; the regex doesn't track multi-line comment state. + +These are the right places for a future tree-sitter upgrade to land. Until then, +the fix for "this exact codebase trips a blind spot" is a fixture. + +## File extensions + +Match regex: `/\.rs$/`. Covers: + +- `.rs` — Rust source files + +Deliberately **not** matched: + +- `.rss` — RSS feeds (not Rust) +- `.rlib` — compiled Rust library (binary, not source) +- `Cargo.toml` / `Cargo.lock` — manifests, not source diff --git a/src/languages/index.ts b/src/languages/index.ts index d95a975..f0436f2 100644 --- a/src/languages/index.ts +++ b/src/languages/index.ts @@ -9,6 +9,7 @@ import { csExtractor } from './cs.js'; import { goExtractor } from './go.js'; import { javaExtractor } from './java.js'; import { pyExtractor } from './py.js'; +import { rustExtractor } from './rust.js'; import { tsExtractor } from './ts.js'; export const extractors: LanguageExtractor[] = [ @@ -17,6 +18,7 @@ export const extractors: LanguageExtractor[] = [ csExtractor, goExtractor, javaExtractor, + rustExtractor, ]; export function extractorFor(path: string): LanguageExtractor | undefined { diff --git a/src/languages/rust.ts b/src/languages/rust.ts new file mode 100644 index 0000000..12f6ba4 --- /dev/null +++ b/src/languages/rust.ts @@ -0,0 +1,26 @@ +import type { CodeSymbol, LanguageExtractor } from '../types.js'; +import { runPatterns } from './runner.js'; + +const PATTERNS: Array<{ re: RegExp; kind: CodeSymbol['kind']; callable?: boolean }> = [ + { + re: /^(?:pub(?:\([^)]*\))?\s+)?(?:(?:async|unsafe|const)\s+)*(?:extern\s+"[^"]*"\s+)?fn\s+(\w+)/, + kind: 'function', + callable: true, + }, + { re: /^(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)/, kind: 'class' }, + { re: /^(?:pub(?:\([^)]*\))?\s+)?enum\s+(\w+)/, kind: 'type' }, + { re: /^(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?trait\s+(\w+)/, kind: 'interface' }, + { re: /^(?:pub(?:\([^)]*\))?\s+)?type\s+(\w+)/, kind: 'type' }, + { re: /^(?:pub(?:\([^)]*\))?\s+)?const\s+(\w+)\s*:/, kind: 'const' }, +]; + +const EXPORT_RE = /^pub\s/; + +export const rustExtractor: LanguageExtractor = { + matches(path: string): boolean { + return /\.rs$/.test(path); + }, + extract(lines: string[]) { + return runPatterns(lines, PATTERNS, (line) => EXPORT_RE.test(line)); + }, +}; diff --git a/tests/fixtures/feat-rust-lexer.diff b/tests/fixtures/feat-rust-lexer.diff new file mode 100644 index 0000000..99938c0 --- /dev/null +++ b/tests/fixtures/feat-rust-lexer.diff @@ -0,0 +1,22 @@ +diff --git a/src/parser/lexer.rs b/src/parser/lexer.rs +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/src/parser/lexer.rs +@@ -0,0 +1,16 @@ ++pub struct Lexer { ++ input: String, ++} ++ ++pub fn tokenize(src: &str) -> Vec { ++ Vec::new() ++} ++ ++pub enum Token { ++ Ident, ++ Number, ++} ++ ++pub const MAX_TOKENS: usize = 1024; ++ ++fn helper() {} diff --git a/tests/fixtures/feat-rust-lexer.expected.txt b/tests/fixtures/feat-rust-lexer.expected.txt new file mode 100644 index 0000000..2649ce4 --- /dev/null +++ b/tests/fixtures/feat-rust-lexer.expected.txt @@ -0,0 +1 @@ +feat(parser): add Lexer and others diff --git a/tests/languages-contract.test.ts b/tests/languages-contract.test.ts index 1539d06..28c4a4a 100644 --- a/tests/languages-contract.test.ts +++ b/tests/languages-contract.test.ts @@ -4,6 +4,7 @@ import { csExtractor } from '../src/languages/cs.js'; import { goExtractor } from '../src/languages/go.js'; import { javaExtractor } from '../src/languages/java.js'; import { pyExtractor } from '../src/languages/py.js'; +import { rustExtractor } from '../src/languages/rust.js'; import { tsExtractor } from '../src/languages/ts.js'; const allCases: Array<{ name: string; ex: LanguageExtractor; classLine: string }> = [ @@ -12,6 +13,7 @@ const allCases: Array<{ name: string; ex: LanguageExtractor; classLine: string } { name: 'cs', ex: csExtractor, classLine: 'public class Foo {}' }, { name: 'go', ex: goExtractor, classLine: 'type Foo struct {}' }, { name: 'java', ex: javaExtractor, classLine: 'public class Foo {}' }, + { name: 'rust', ex: rustExtractor, classLine: 'pub struct Foo {}' }, ]; const callableCases: Array<{ @@ -34,6 +36,12 @@ const callableCases: Array<{ callLine: 'public delegate int Bar();', }, { name: 'go', ex: goExtractor, classLine: 'type Foo struct {}', callLine: 'func Bar() {}' }, + { + name: 'rust', + ex: rustExtractor, + classLine: 'pub struct Foo {}', + callLine: 'pub fn bar() {}', + }, ]; describe('Language contract — all extractors', () => { diff --git a/tests/languages-index.test.ts b/tests/languages-index.test.ts index 9116b09..eb82153 100644 --- a/tests/languages-index.test.ts +++ b/tests/languages-index.test.ts @@ -5,6 +5,7 @@ import { csExtractor } from '../src/languages/cs.js'; import { goExtractor } from '../src/languages/go.js'; import { javaExtractor } from '../src/languages/java.js'; import { pyExtractor } from '../src/languages/py.js'; +import { rustExtractor } from '../src/languages/rust.js'; import { tsExtractor } from '../src/languages/ts.js'; const file = (overrides: Partial = {}): FileChange => ({ @@ -35,6 +36,10 @@ describe('extractors registry', () => { it('includes javaExtractor', () => { expect(extractors).toContain(javaExtractor); }); + + it('includes rustExtractor', () => { + expect(extractors).toContain(rustExtractor); + }); }); describe('extractorFor', () => { @@ -58,8 +63,14 @@ describe('extractorFor', () => { expect(extractorFor(p)).toBe(javaExtractor), ); + it.each(['a.rs', 'src/main.rs', 'crates/core/src/lib.rs'])('returns rustExtractor for %s', (p) => + expect(extractorFor(p)).toBe(rustExtractor), + ); + it.each([ - 'a.rs', + 'a.rss', + 'Cargo.toml', + 'a.rlib', 'a.gohtml', 'a.tmpl', 'go.mod', diff --git a/tests/languages-rust.test.ts b/tests/languages-rust.test.ts new file mode 100644 index 0000000..e83cf03 --- /dev/null +++ b/tests/languages-rust.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; +import type { CodeSymbol } from '../src/types.js'; +import { rustExtractor } from '../src/languages/rust.js'; + +describe('rustExtractor', () => { + describe('matches', () => { + it.each(['lib.rs', 'src/main.rs', 'a.rs', 'crates/core/src/parser.rs'])('matches %s', (p) => + expect(rustExtractor.matches(p)).toBe(true), + ); + it.each(['a.rss', 'Cargo.toml', 'a.rlib', 'a.md', 'a', 'main.go'])('does not match %s', (p) => + expect(rustExtractor.matches(p)).toBe(false), + ); + }); + + describe('extract', () => { + it.each<{ name: string; line: string; expected: CodeSymbol }>([ + { + name: 'pub fn', + line: 'pub fn tokenize(src: &str) -> Vec {', + expected: { kind: 'function', name: 'tokenize', exported: true, params: 'src: &str' }, + }, + { + name: 'private fn', + line: 'fn helper() {', + expected: { kind: 'function', name: 'helper', exported: false, params: '' }, + }, + { + name: 'pub async fn', + line: 'pub async fn fetch(url: &str) {', + expected: { kind: 'function', name: 'fetch', exported: true, params: 'url: &str' }, + }, + { + name: 'pub unsafe fn', + line: 'pub unsafe fn raw() {', + expected: { kind: 'function', name: 'raw', exported: true, params: '' }, + }, + { + name: 'pub extern "C" fn', + line: 'pub extern "C" fn ffi() {', + expected: { kind: 'function', name: 'ffi', exported: true, params: '' }, + }, + { + name: 'pub fn with generic', + line: 'pub fn map(items: Vec) -> Vec {', + expected: { kind: 'function', name: 'map', exported: true, params: 'items: Vec' }, + }, + { + name: 'pub struct', + line: 'pub struct Lexer {', + expected: { kind: 'class', name: 'Lexer', exported: true }, + }, + { + name: 'pub tuple struct', + line: 'pub struct Point(i32, i32);', + expected: { kind: 'class', name: 'Point', exported: true }, + }, + { + name: 'private struct', + line: 'struct Inner {', + expected: { kind: 'class', name: 'Inner', exported: false }, + }, + { + name: 'pub enum', + line: 'pub enum Token {', + expected: { kind: 'type', name: 'Token', exported: true }, + }, + { + name: 'pub trait', + line: 'pub trait Visitor {', + expected: { kind: 'interface', name: 'Visitor', exported: true }, + }, + { + name: 'pub unsafe trait', + line: 'pub unsafe trait Sync {', + expected: { kind: 'interface', name: 'Sync', exported: true }, + }, + { + name: 'private trait', + line: 'trait Helper {', + expected: { kind: 'interface', name: 'Helper', exported: false }, + }, + { + name: 'pub type alias', + line: 'pub type NodeId = usize;', + expected: { kind: 'type', name: 'NodeId', exported: true }, + }, + { + name: 'pub const', + line: 'pub const MAX_TOKENS: usize = 1024;', + expected: { kind: 'const', name: 'MAX_TOKENS', exported: true }, + }, + { + name: 'private const', + line: 'const BUF: usize = 8;', + expected: { kind: 'const', name: 'BUF', exported: false }, + }, + ])('extracts $name', ({ line, expected }) => { + expect(rustExtractor.extract([line])).toEqual([expected]); + }); + + it.each([ + '// pub fn commented() {', + '/// pub fn doc() {', + 'let x = 5;', + 'use std::collections::HashMap;', + 'mod parser;', + 'impl Lexer {', + '#[derive(Debug)]', + 'macro_rules! vec_of {', + 'static GLOBAL: u32 = 0;', + 'union MyUnion {', + ])('does not extract %s', (line) => { + expect(rustExtractor.extract([line])).toEqual([]); + }); + + it('does not extract indented declarations (methods in impl/trait blocks)', () => { + expect(rustExtractor.extract([' pub fn method(&self) {', ' fn helper() {'])).toEqual( + [], + ); + }); + + it('treats `const fn` as a function, not a const (row order)', () => { + expect(rustExtractor.extract(['pub const fn floor(x: f64) -> f64 {'])).toEqual([ + { kind: 'function', name: 'floor', exported: true, params: 'x: f64' }, + ]); + }); + + it('treats `const NAME:` as a const', () => { + expect(rustExtractor.extract(['pub const MAX: usize = 1;'])).toEqual([ + { kind: 'const', name: 'MAX', exported: true }, + ]); + }); + + it('treats pub(crate) as not exported', () => { + expect(rustExtractor.extract(['pub(crate) struct Internal {'])).toEqual([ + { kind: 'class', name: 'Internal', exported: false }, + ]); + }); + + it('treats pub(super) as not exported', () => { + expect(rustExtractor.extract(['pub(super) enum Mode {'])).toEqual([ + { kind: 'type', name: 'Mode', exported: false }, + ]); + }); + + it('blind spot: pub(crate) fn params capture the visibility paren, not the args', () => { + expect(rustExtractor.extract(['pub(crate) fn internal(a: i32) {'])).toEqual([ + { kind: 'function', name: 'internal', exported: false, params: 'crate' }, + ]); + }); + + it('dedupes identical symbols within one call', () => { + const lines = ['pub struct Foo {', 'pub struct Foo {']; + expect(rustExtractor.extract(lines)).toEqual([ + { kind: 'class', name: 'Foo', exported: true }, + ]); + }); + + it('preserves declaration order across lines', () => { + expect(rustExtractor.extract(['pub struct A {', 'pub struct B {'])).toEqual([ + { kind: 'class', name: 'A', exported: true }, + { kind: 'class', name: 'B', exported: true }, + ]); + }); + + it('sets params on fn but not on type-definition kinds', () => { + const lines = [ + 'pub fn run(cfg: Config) {', + 'pub struct S {', + 'pub enum E {', + 'pub trait T {', + 'pub type A = u8;', + 'pub const C: u8 = 1;', + ]; + const result = rustExtractor.extract(lines); + expect(result).toHaveLength(6); + expect(result.find((s) => s.name === 'run')?.params).toBe('cfg: Config'); + for (const sym of result.filter((s) => s.name !== 'run')) { + expect(sym.params).toBeUndefined(); + } + }); + }); +});