feat(scala): add Scala language provider and all extractors

Full tree-sitter-based Scala support using tree-sitter-scala@0.24.0
(N-API binding, ABI version 14 — no vendoring needed):
- SCALA_QUERIES: class/trait/object/enum definitions, method defs +
  declarations, import declarations, field/free/constructor calls,
  val/var declarations, extends heritage, field assignments
- Type extractor with val/var type annotations and constructor binding
- All pipeline extractors following Java/Go provider pattern
- Export checker (public by default, like Kotlin)
- JVM import resolver reusing resolveJvmWildcard/resolveJvmMemberImport
- CLASS_CONTAINER_TYPES and CONTAINER_TYPE_TO_LABEL extended for Scala
  (object_definition, trait_definition, enum_definition)
- FUNCTION_NODE_TYPES extended with function_definition
This commit is contained in:
Christian C. Berclaz 2026-05-01 23:03:45 +02:00
parent 48765ef8ce
commit 9c6caff879
No known key found for this signature in database
17 changed files with 525 additions and 0 deletions

View file

@ -40,6 +40,7 @@
"tree-sitter-python": "0.23.4",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "0.23.1",
"tree-sitter-scala": "^0.24.0",
"tree-sitter-typescript": "^0.23.2",
"uuid": "^14.0.0"
},
@ -5203,6 +5204,25 @@
}
}
},
"node_modules/tree-sitter-scala": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/tree-sitter-scala/-/tree-sitter-scala-0.24.0.tgz",
"integrity": "sha512-vkMuAUrBZ1zZz2XcGDQk18Kz73JkpgaeXzbNVobPke0G35sd9jH32aUxG6OLRKM7et0TbsfqkWf4DeJoGk4K1g==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.2.2",
"node-gyp-build": "^4.8.2"
},
"peerDependencies": {
"tree-sitter": "^0.21.1"
},
"peerDependenciesMeta": {
"tree-sitter": {
"optional": true
}
}
},
"node_modules/tree-sitter-swift": {
"resolved": "vendor/tree-sitter-swift",
"link": true

View file

@ -82,6 +82,7 @@
"tree-sitter-python": "0.23.4",
"tree-sitter-ruby": "^0.23.1",
"tree-sitter-rust": "0.23.1",
"tree-sitter-scala": "^0.24.0",
"tree-sitter-typescript": "^0.23.2",
"uuid": "^14.0.0"
},

View file

@ -0,0 +1,6 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { CallExtractionConfig } from '../../call-types.js';
export const scalaCallConfig: CallExtractionConfig = {
language: SupportedLanguages.Scala,
};

View file

@ -0,0 +1,33 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { ClassExtractionConfig, ClassLikeNodeLabel } from '../../class-types.js';
export const scalaClassConfig: ClassExtractionConfig = {
language: SupportedLanguages.Scala,
typeDeclarationNodes: [
'class_definition',
'object_definition',
'trait_definition',
'enum_definition',
],
fileScopeNodeTypes: ['compilation_unit'],
ancestorScopeNodeTypes: ['class_definition', 'object_definition', 'trait_definition'],
extractName(node) {
return node.childForFieldName('name')?.text;
},
extractType(node): ClassLikeNodeLabel | undefined {
switch (node.type) {
case 'class_definition':
return 'Class';
case 'trait_definition':
return 'Interface';
case 'object_definition':
return 'Class';
case 'enum_definition':
return 'Enum';
default:
return undefined;
}
},
};

View file

@ -246,3 +246,26 @@ export const rubyExportChecker: ExportChecker = (_node, _name) => true;
/** Dart: public if no leading underscore (convention, same as Python). */
export const dartExportChecker: ExportChecker = (_node, name) => !name.startsWith('_');
/**
* Scala: default visibility is public (like Kotlin).
* Only private/protected are non-exported. Walk up to find modifier.
*/
export const scalaExportChecker: ExportChecker = (node, _name) => {
let current: SyntaxNode | null = node;
while (current) {
if (current.type === 'modifiers') {
const text = current.text;
if (text?.includes('private') || text?.includes('protected')) return false;
}
for (let i = 0; i < (current.childCount ?? 0); i++) {
const child = current.child?.(i);
if (child?.type === 'modifiers') {
const text = child.text;
if (text?.includes('private') || text?.includes('protected')) return false;
}
}
current = current.parent;
}
return true;
};

View file

@ -0,0 +1,42 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { FieldExtractionConfig } from '../generic.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
export const scalaFieldConfig: FieldExtractionConfig = {
language: SupportedLanguages.Scala,
typeDeclarationNodes: ['class_definition', 'object_definition', 'trait_definition'],
fieldNodeTypes: ['val_definition', 'var_definition', 'val_declaration', 'var_declaration'],
bodyNodeTypes: ['template_body'],
defaultVisibility: 'public',
extractName(node) {
const pattern = node.childForFieldName('pattern');
if (pattern?.type === 'identifier') return pattern.text;
return undefined;
},
extractType(node) {
const typeNode = node.childForFieldName('type');
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
return undefined;
},
extractVisibility(node) {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
if (child.text?.includes('private')) return 'private';
if (child.text?.includes('protected')) return 'protected';
}
}
return 'public';
},
isStatic(_node) {
return false;
},
isReadonly(node) {
return node.type === 'val_definition' || node.type === 'val_declaration';
},
};

View file

@ -0,0 +1,6 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { HeritageExtractionConfig } from '../../heritage-types.js';
export const scalaHeritageConfig: HeritageExtractionConfig = {
language: SupportedLanguages.Scala,
};

View file

@ -0,0 +1,47 @@
/**
* Scala import resolution config.
* Reuses JVM wildcard/member import strategy, then standard fallback.
*/
import { SupportedLanguages } from 'gitnexus-shared';
import type { ImportResolutionConfig, ImportResolverStrategy } from '../types.js';
import { createStandardStrategy } from '../standard.js';
import { resolveJvmWildcard, resolveJvmMemberImport } from '../jvm.js';
export const scalaJvmStrategy: ImportResolverStrategy = (rawImportPath, _filePath, ctx) => {
const dotPath = rawImportPath
.replace(/\{[^}]*\}/g, '')
.trim()
.replace(/\._$/, '.*');
const exts = ['.scala', '.java'];
if (dotPath.endsWith('.*') || dotPath.endsWith('._')) {
const files = resolveJvmWildcard(
dotPath,
ctx.normalizedFileList,
ctx.allFileList,
exts,
ctx.index,
);
if (files.length > 0) {
const pkgPath = dotPath.slice(0, -2).replace(/\./g, '/');
return { kind: 'package', files, dirSuffix: pkgPath };
}
return null;
}
const resolved = resolveJvmMemberImport(
dotPath,
ctx.normalizedFileList,
ctx.allFileList,
exts,
ctx.index,
);
return resolved ? { kind: 'files', files: [resolved] } : null;
};
export const scalaImportConfig: ImportResolutionConfig = {
language: SupportedLanguages.Scala,
strategies: [scalaJvmStrategy, createStandardStrategy(SupportedLanguages.Scala)],
};

View file

@ -47,6 +47,9 @@ export const EXTENSIONS = [
'.swift',
// Ruby
'.rb',
// Scala
'.scala',
'.sc',
];
/**

View file

@ -24,6 +24,7 @@ import { rubyProvider } from './ruby.js';
import { swiftProvider } from './swift.js';
import { dartProvider } from './dart.js';
import { vueProvider } from './vue.js';
import { scalaProvider } from './scala.js';
import { cobolProvider } from './cobol.js';
export const providers = {
@ -41,6 +42,7 @@ export const providers = {
[SupportedLanguages.Ruby]: rubyProvider,
[SupportedLanguages.Swift]: swiftProvider,
[SupportedLanguages.Dart]: dartProvider,
[SupportedLanguages.Scala]: scalaProvider,
[SupportedLanguages.Vue]: vueProvider,
[SupportedLanguages.Cobol]: cobolProvider,
} satisfies Record<SupportedLanguages, LanguageProvider>;

View file

@ -0,0 +1,71 @@
/**
* Scala Language Provider
*
* Assembles all Scala-specific ingestion capabilities into a single
* LanguageProvider, following the Strategy pattern used by the pipeline.
*
* Key Scala traits:
* - importSemantics: 'named' (explicit imports like Java/Kotlin)
* - JVM package resolution reused from Java
* - class/trait/object as first-class constructs
* - Methods are function_definition inside template_body
*/
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { scalaClassConfig } from '../class-extractors/configs/scala.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as scalaConfig } from '../type-extractors/scala.js';
import { scalaExportChecker } from '../export-detection.js';
import { createImportResolver } from '../import-resolvers/resolver-factory.js';
import { scalaImportConfig } from '../import-resolvers/configs/scala.js';
import { SCALA_QUERIES } from '../tree-sitter-queries.js';
import { createFieldExtractor } from '../field-extractors/generic.js';
import { scalaFieldConfig } from '../field-extractors/configs/scala.js';
import { createMethodExtractor } from '../method-extractors/generic.js';
import { scalaMethodConfig } from '../method-extractors/configs/scala.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { scalaVariableConfig } from '../variable-extractors/configs/scala.js';
import { createCallExtractor } from '../call-extractors/generic.js';
import { scalaCallConfig } from '../call-extractors/configs/scala.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import { scalaHeritageConfig } from '../heritage-extractors/configs/scala.js';
const BUILT_INS: ReadonlySet<string> = new Set([
'println',
'print',
'require',
'assert',
'assume',
'throw',
'sys',
'classOf',
'isInstanceOf',
'asInstanceOf',
'toString',
'hashCode',
'equals',
'getClass',
'synchronized',
'wait',
'notify',
'notifyAll',
]);
export const scalaProvider = defineLanguage({
id: SupportedLanguages.Scala,
extensions: ['.scala', '.sc'],
treeSitterQueries: SCALA_QUERIES,
typeConfig: scalaConfig,
exportChecker: scalaExportChecker,
importResolver: createImportResolver(scalaImportConfig),
importSemantics: 'named',
mroStrategy: 'implements-split',
callExtractor: createCallExtractor(scalaCallConfig),
fieldExtractor: createFieldExtractor(scalaFieldConfig),
methodExtractor: createMethodExtractor(scalaMethodConfig),
variableExtractor: createVariableExtractor(scalaVariableConfig),
classExtractor: createClassExtractor(scalaClassConfig),
heritageExtractor: createHeritageExtractor(scalaHeritageConfig),
builtInNames: BUILT_INS,
});

View file

@ -0,0 +1,83 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { MethodExtractionConfig, ParameterInfo } from '../../method-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
function extractScalaName(node: SyntaxNode): string | undefined {
return node.childForFieldName('name')?.text;
}
function extractScalaReturnType(node: SyntaxNode): string | undefined {
const retType = node.childForFieldName('return_type');
if (retType) return extractSimpleTypeName(retType) ?? retType.text?.trim();
return undefined;
}
function extractScalaParameters(node: SyntaxNode): ParameterInfo[] {
const params: ParameterInfo[] = [];
for (let i = 0; i < node.namedChildCount; i++) {
const child = node.namedChild(i);
if (child?.type !== 'parameters') continue;
for (let j = 0; j < child.namedChildCount; j++) {
const param = child.namedChild(j);
if (param?.type !== 'parameter') continue;
const nameNode = param.childForFieldName('name');
const typeNode = param.childForFieldName('type');
const typeName = typeNode
? (extractSimpleTypeName(typeNode) ?? typeNode.text?.trim() ?? null)
: null;
params.push({
name: nameNode?.text ?? `_${j}`,
type: typeName,
rawType: typeNode?.text?.trim() ?? null,
isOptional: false,
isVariadic: param.text?.includes('*') ?? false,
});
}
}
return params;
}
function extractScalaVisibility(node: SyntaxNode): 'public' | 'private' | 'protected' {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
if (child.text?.includes('private')) return 'private';
if (child.text?.includes('protected')) return 'protected';
}
}
return 'public';
}
export const scalaMethodConfig: MethodExtractionConfig = {
language: SupportedLanguages.Scala,
typeDeclarationNodes: [
'class_definition',
'object_definition',
'trait_definition',
'enum_definition',
],
methodNodeTypes: ['function_definition', 'function_declaration'],
bodyNodeTypes: ['template_body'],
extractName: extractScalaName,
extractReturnType: extractScalaReturnType,
extractParameters: extractScalaParameters,
extractVisibility: extractScalaVisibility,
isStatic(_node) {
return false;
},
isAbstract(node) {
return node.type === 'function_declaration';
},
isFinal(node) {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers' && child.text?.includes('final')) return true;
}
return false;
},
};

View file

@ -1373,6 +1373,62 @@ export const DART_QUERIES = `
(type_identifier) @heritage.trait))) @heritage
`;
export const SCALA_QUERIES = `
; Classes
(class_definition name: (identifier) @name) @definition.class
; Traits
(trait_definition name: (identifier) @name) @definition.trait
; Objects (companion or standalone)
(object_definition name: (identifier) @name) @definition.class
; Enums (Scala 3)
(enum_definition name: (identifier) @name) @definition.enum
; Methods (def inside class/trait/object)
(function_definition name: (identifier) @name) @definition.method
; Abstract method declarations
(function_declaration name: (identifier) @name) @definition.method
; Imports — capture the whole declaration (path extracted from text)
(import_declaration) @import
; Calls — field access: obj.method(...)
(call_expression
function: (field_expression
field: (identifier) @call.name)) @call
; Calls — constructor: new Foo(...)
(instance_expression (type_identifier) @call.name) @call
; Calls — free function (rare in Scala, but possible)
(call_expression function: (identifier) @call.name) @call
; Val declarations
(val_definition pattern: (identifier) @name) @definition.const
; Var declarations
(var_definition pattern: (identifier) @name) @definition.variable
; Heritage — extends
(class_definition
name: (identifier) @heritage.class
(extends_clause (type_identifier) @heritage.extends)) @heritage
; Heritage — trait extends trait
(trait_definition
name: (identifier) @heritage.class
(extends_clause (type_identifier) @heritage.extends)) @heritage
; Write access: obj.field = value
(assignment_expression
left: (field_expression
value: (_) @assignment.receiver
field: (identifier) @assignment.property)) @assignment
`;
import { SupportedLanguages } from 'gitnexus-shared';
export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
@ -1390,6 +1446,7 @@ export const LANGUAGE_QUERIES: Record<SupportedLanguages, string> = {
[SupportedLanguages.Ruby]: RUBY_QUERIES,
[SupportedLanguages.Swift]: SWIFT_QUERIES,
[SupportedLanguages.Dart]: DART_QUERIES,
[SupportedLanguages.Scala]: SCALA_QUERIES,
[SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue <script> blocks are parsed as TypeScript
[SupportedLanguages.Cobol]: '', // Standalone regex processor — no tree-sitter queries
};

View file

@ -0,0 +1,68 @@
import type { SyntaxNode } from '../utils/ast-helpers.js';
import type {
ConstructorBindingScanner,
LanguageTypeConfig,
ParameterExtractor,
TypeBindingExtractor,
} from './types.js';
import { extractSimpleTypeName, extractVarName } from './shared.js';
const DECLARATION_NODE_TYPES: ReadonlySet<string> = new Set([
'val_definition',
'var_definition',
]);
const extractDeclaration: TypeBindingExtractor = (
node: SyntaxNode,
env: Map<string, string>,
): void => {
const pattern = node.childForFieldName('pattern');
const typeNode = node.childForFieldName('type');
if (!pattern || !typeNode) return;
const varName = extractVarName(pattern);
const typeName = extractSimpleTypeName(typeNode);
if (varName && typeName) env.set(varName, typeName);
};
const extractParameter: ParameterExtractor = (
node: SyntaxNode,
env: Map<string, string>,
): void => {
if (node.type !== 'parameter' && node.type !== 'class_parameter') return;
const nameNode = node.childForFieldName('name');
const typeNode = node.childForFieldName('type');
if (!nameNode || !typeNode) return;
const varName = extractVarName(nameNode);
const typeName = extractSimpleTypeName(typeNode);
if (varName && typeName) env.set(varName, typeName);
};
const scanConstructorBinding: ConstructorBindingScanner = (node) => {
if (node.type !== 'val_definition') return undefined;
const pattern = node.childForFieldName('pattern');
const value = node.childForFieldName('value');
if (!pattern || !value) return undefined;
const varName = extractVarName(pattern);
if (!varName) return undefined;
if (value.type === 'call_expression') {
const funcNode = value.childForFieldName('function');
if (funcNode) {
const calleeName = funcNode.text;
if (calleeName) return { varName, calleeName };
}
}
if (value.type === 'instance_expression') {
const typeNode = value.namedChildren.find((c: SyntaxNode) => c.type === 'type_identifier');
if (typeNode) return { varName, calleeName: typeNode.text };
}
return undefined;
};
export const typeConfig: LanguageTypeConfig = {
declarationNodeTypes: DECLARATION_NODE_TYPES,
forLoopNodeTypes: new Set(['for_expression']),
extractDeclaration,
extractParameter,
scanConstructorBinding,
};

View file

@ -95,6 +95,8 @@ export const FUNCTION_NODE_TYPES = new Set([
// Dart
'function_signature',
'method_signature',
// Scala
'function_definition',
]);
/**
@ -132,6 +134,10 @@ export const CLASS_CONTAINER_TYPES = new Set([
// Kotlin
'object_declaration',
'companion_object',
// Scala
'object_definition',
'trait_definition',
'enum_definition',
]);
export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
@ -164,6 +170,9 @@ export const CONTAINER_TYPE_TO_LABEL: Record<string, string> = {
singleton_class: 'Class', // Ruby: class << self inherits enclosing class name
object_declaration: 'Class',
companion_object: 'Class',
object_definition: 'Class',
trait_definition: 'Trait',
enum_definition: 'Enum',
};
/** Return the first matching ancestor unless a boundary ancestor is reached first. */

View file

@ -0,0 +1,49 @@
import { SupportedLanguages } from 'gitnexus-shared';
import type { VariableExtractionConfig, VariableVisibility } from '../../variable-types.js';
import { extractSimpleTypeName } from '../../type-extractors/shared.js';
import type { SyntaxNode } from '../../utils/ast-helpers.js';
function extractScalaVarName(node: SyntaxNode): string | undefined {
const pattern = node.childForFieldName('pattern');
if (pattern?.type === 'identifier') return pattern.text;
return undefined;
}
function extractScalaVarType(node: SyntaxNode): string | undefined {
const typeNode = node.childForFieldName('type');
if (typeNode) return extractSimpleTypeName(typeNode) ?? typeNode.text?.trim();
return undefined;
}
export const scalaVariableConfig: VariableExtractionConfig = {
language: SupportedLanguages.Scala,
constNodeTypes: ['val_definition'],
staticNodeTypes: [],
variableNodeTypes: ['var_definition'],
extractName: extractScalaVarName,
extractType: extractScalaVarType,
extractVisibility(node): VariableVisibility {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifiers') {
if (child.text?.includes('private')) return 'private';
if (child.text?.includes('protected')) return 'protected';
}
}
return 'public';
},
isConst(node) {
return node.type === 'val_definition';
},
isStatic(_node) {
return false;
},
isMutable(node) {
return node.type === 'var_definition';
},
};

View file

@ -153,6 +153,11 @@ const SOURCES: Record<string, GrammarSource> = {
'Likely cause: native compile failed at install (missing python3/make/g++). ' +
`See ${ISSUES_URL}/1125.`,
},
[SupportedLanguages.Scala]: {
load: () => _require('tree-sitter-scala'),
unavailableNote:
'Scala parsing requires `tree-sitter-scala`. Check the install and native binding.',
},
[SupportedLanguages.Kotlin]: {
load: () => _require('tree-sitter-kotlin'),
optional: true,