export type FileKind = 'source' | 'tests' | 'docs'
export type Classification = {
countable: boolean
language: string
kind: FileKind
reason: string
}
const LOCKFILE_NAMES = new Set([
'package-lock.json',
'pnpm-lock.yaml',
'yarn.lock',
'bun.lock',
'bun.lockb',
'cargo.lock',
'poetry.lock',
'composer.lock',
'gemfile.lock',
'go.sum',
'flake.lock',
])
const BINARY_EXTENSIONS = new Set([
'png',
'jpg',
'jpeg',
'gif',
'webp',
'ico',
'bmp',
'tif',
'tiff',
'avif',
'svg',
'mp3',
'mp4',
'wav',
'ogg',
'webm',
'mov',
'woff',
'woff2',
'ttf',
'otf',
'eot',
'pdf',
'zip',
'gz',
'tgz',
'bz2',
'7z',
'rar',
'wasm',
'exe',
'dll',
'so',
'dylib',
'bin',
'class',
'o',
'a',
'jar',
'apk',
'ipa',
'icns',
'psd',
'fig',
'sketch',
])
const DOC_EXTENSIONS = new Set([
'md',
'mdx',
'rst',
'adoc',
'asciidoc',
'txt',
])
const DOC_BASENAMES = new Set([
'readme',
'changelog',
'changes',
'contributing',
'code_of_conduct',
'security',
'authors',
'license',
'licence',
'copying',
'notice',
'patent',
])
const LANGUAGE_BY_EXTENSION: Record<string, string> = {
ts: 'TypeScript',
tsx: 'TypeScript',
mts: 'TypeScript',
cts: 'TypeScript',
js: 'JavaScript',
jsx: 'JavaScript',
mjs: 'JavaScript',
cjs: 'JavaScript',
css: 'CSS',
scss: 'SCSS',
sass: 'Sass',
less: 'Less',
html: 'HTML',
htm: 'HTML',
json: 'JSON',
jsonc: 'JSON',
yml: 'YAML',
yaml: 'YAML',
toml: 'TOML',
sql: 'SQL',
graphql: 'GraphQL',
gql: 'GraphQL',
sh: 'Shell',
bash: 'Shell',
zsh: 'Shell',
fish: 'Shell',
ps1: 'PowerShell',
py: 'Python',
rb: 'Ruby',
go: 'Go',
rs: 'Rust',
java: 'Java',
kt: 'Kotlin',
kts: 'Kotlin',
swift: 'Swift',
c: 'C',
h: 'C',
cc: 'C++',
cpp: 'C++',
cxx: 'C++',
hpp: 'C++',
cs: 'C#',
php: 'PHP',
lua: 'Lua',
r: 'R',
pl: 'Perl',
scala: 'Scala',
clj: 'Clojure',
ex: 'Elixir',
exs: 'Elixir',
erl: 'Erlang',
hs: 'Haskell',
ml: 'OCaml',
vue: 'Vue',
svelte: 'Svelte',
astro: 'Astro',
tf: 'HCL',
hcl: 'HCL',
nix: 'Nix',
dockerfile: 'Dockerfile',
makefile: 'Makefile',
mk: 'Makefile',
cmake: 'CMake',
gradle: 'Gradle',
proto: 'Protocol Buffers',
wasm: 'WebAssembly',
wat: 'WebAssembly',
}
const GENERATED_PATH_RULES: Array<{ reason: string; test: (path: string) => boolean }> = [
{
reason: 'lockfile',
test: (path) => LOCKFILE_NAMES.has(basename(path).toLowerCase()),
},
{
reason: 'generated-directory',
test: (path) =>
/(?:^|\/)(?:generated|__generated__|codegen|\.generated)(?:\/|$)/i.test(
path,
),
},
{
reason: 'generated-suffix',
test: (path) =>
/\.(?:generated|gen)\.[^/]+$/i.test(path) ||
/\.gen\.[cm]?[jt]sx?$/i.test(path) ||
/routeTree\.gen\.[cm]?[jt]sx?$/i.test(path),
},
{
reason: 'generated-declaration-stub',
test: (path) => /(?:^|\/)generated-[^/]+\.d\.ts$/i.test(path),
},
{
reason: 'generated-prefixed-module',
test: (path) => {
const name = basename(path)
if (name === 'generated-username.ts') return false
return /^generated-[^/]+\.(?:[cm]?[jt]sx?)$/i.test(name)
},
},
{
reason: 'wrangler-types',
test: (path) => /(?:^|\/)worker-configuration\.d\.ts$/i.test(path),
},
{
reason: 'wrangler-generated-config',
test: (path) => /wrangler(?:-[^/]+)?\.generated\.json$/i.test(path),
},
{
reason: 'minified-or-sourcemap',
test: (path) => /\.min\.(?:js|css)$/i.test(path) || /\.map$/i.test(path),
},
{
reason: 'snapshot-artifact',
test: (path) =>
/\.snap$/i.test(path) || /(?:^|\/)__snapshots__\//i.test(path),
},
{
reason: 'build-output',
test: (path) =>
/(?:^|\/)(?:dist|dist-ssr|build|out|\.next|coverage|storybook-static|playwright-report|test-results)(?:\/|$)/i.test(
path,
),
},
{
reason: 'vendor-or-deps',
test: (path) =>
/(?:^|\/)(?:node_modules|vendor|third_party|\.nx|\.wrangler)(?:\/|$)/i.test(
path,
),
},
{
reason: 'generated-client-bundle',
test: (path) =>
/(?:^|\/)(?:client-entry\.js|client-manifest\.json)$/i.test(path),
},
]
const GENERATED_CONTENT_MARKERS = [
/\b@generated\b/,
/Generated by Wrangler/i,
/Code generated by /i,
/This file (?:was|is) (?:automatically )?generated/i,
/auto-generated (?:file|by)/i,
/DO NOT EDIT\b/,
/do not edit (?:this|by hand)/i,
/eslint-disable \*\/\s*\n\/\/ Generated by/i,
]
export type IgnoreIndex = {
gitignore: string[]
linguistGenerated: string[]
knownGeneratedPaths: Set<string>
}
export function emptyIgnoreIndex(): IgnoreIndex {
return {
gitignore: [],
linguistGenerated: [],
knownGeneratedPaths: new Set(),
}
}
export function parseGitignore(text: string): string[] {
return text
.split(/\r?\n/)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
}
export function parseLinguistGenerated(gitattributes: string): string[] {
const patterns: string[] = []
for (const raw of gitattributes.split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith('#')) continue
if (!/\blinguist-generated(?:=true)?\b/.test(line)) continue
if (/\blinguist-generated=false\b/.test(line)) continue
const pattern = line.split(/\s+/)[0]
if (pattern) patterns.push(pattern)
}
return patterns
}
export function classifyPath(
filename: string,
options: {
patch?: string | null
ignore?: IgnoreIndex
} = {},
): Classification {
const path = filename.replaceAll('\\', '/')
const ignore = options.ignore ?? emptyIgnoreIndex()
if (ignore.knownGeneratedPaths.has(path)) {
return skip('cached-generated-path')
}
if (matchAnyGitPattern(path, ignore.linguistGenerated)) {
return skip('gitattributes-linguist-generated')
}
for (const rule of GENERATED_PATH_RULES) {
if (rule.test(path)) return skip(rule.reason)
}
if (patchLooksGenerated(options.patch)) {
return skip('generated-content-marker')
}
const ext = extension(path)
if (BINARY_EXTENSIONS.has(ext)) return skip('binary')
const language = languageFor(path, ext)
if (!language) return skip('non-code')
return {
countable: true,
language,
kind: kindFor(path, ext),
reason: 'countable',
}
}
export function languageFor(path: string, ext = extension(path)): string | null {
const base = basename(path).toLowerCase()
if (base === 'dockerfile') return 'Dockerfile'
if (base === 'makefile') return 'Makefile'
if (DOC_EXTENSIONS.has(ext) || DOC_BASENAMES.has(stem(base))) {
return 'Markdown'
}
return LANGUAGE_BY_EXTENSION[ext] ?? null
}
export function kindFor(path: string, ext = extension(path)): FileKind {
const lower = path.toLowerCase()
const base = basename(lower)
if (
DOC_EXTENSIONS.has(ext) ||
DOC_BASENAMES.has(stem(base)) ||
/(?:^|\/)docs?\//.test(lower)
) {
return 'docs'
}
if (
/\.(?:test|spec|node\.test|node\.spec)\.[^./]+$/i.test(path) ||
/(?:^|\/)(?:__tests__|tests?|e2e|spec)(?:\/|$)/i.test(lower)
) {
return 'tests'
}
return 'source'
}
export function patchLooksGenerated(patch?: string | null): boolean {
if (!patch) return false
const head = addedPatchHead(patch, 40)
return GENERATED_CONTENT_MARKERS.some((marker) => marker.test(head))
}
export function addedPatchHead(patch: string, lineLimit: number): string {
const lines: string[] = []
for (const line of patch.split(/\r?\n/)) {
if (line.startsWith('+++') || line.startsWith('---') || line.startsWith('@@')) {
continue
}
if (line.startsWith('+')) {
lines.push(line.slice(1))
if (lines.length >= lineLimit) break
}
}
return lines.join('\n')
}
export function matchAnyGitPattern(path: string, patterns: string[]): boolean {
return patterns.some((pattern) => matchGitPattern(path, pattern))
}
export function matchGitPattern(path: string, rawPattern: string): boolean {
let pattern = rawPattern.trim()
if (!pattern) return false
let negated = false
if (pattern.startsWith('!')) {
negated = true
pattern = pattern.slice(1)
}
if (pattern.startsWith('/')) pattern = pattern.slice(1)
const anchored = pattern.includes('/')
const regex = gitPatternToRegExp(pattern, anchored)
const matched = regex.test(path) || regex.test(basename(path))
return negated ? !matched : matched
}
function gitPatternToRegExp(pattern: string, anchored: boolean): RegExp {
let source = ''
for (let i = 0; i < pattern.length; i += 1) {
const char = pattern[i]
if (char === '*' && pattern[i + 1] === '*') {
const next = pattern[i + 2]
if (next === '/') {
source += '(?:.*/)?'
i += 2
} else {
source += '.*'
i += 1
}
continue
}
if (char === '*') {
source += '[^/]*'
continue
}
if (char === '?') {
source += '[^/]'
continue
}
if ('+.^$()[]{}|\\'.includes(char)) {
source += `\\${char}`
continue
}
source += char
}
if (pattern.endsWith('/')) {
source += '.*'
}
const prefix = anchored ? '^' : '(?:^|/)'
return new RegExp(`${prefix}${source}$`, 'i')
}
function skip(reason: string): Classification {
return { countable: false, language: '', kind: 'source', reason }
}
function basename(path: string): string {
const parts = path.split('/')
return parts[parts.length - 1] ?? path
}
function stem(filename: string): string {
return filename.replace(/\.[^.]+$/, '')
}
function extension(path: string): string {
const base = basename(path)
if (base.startsWith('.') && !base.slice(1).includes('.')) return ''
const dot = base.lastIndexOf('.')
return dot === -1 ? '' : base.slice(dot + 1).toLowerCase()
}