From 9c6caff8796175a1cd3b84a13d8095c1d5aa3e29 Mon Sep 17 00:00:00 2001 From: "Christian C. Berclaz" Date: Fri, 1 May 2026 23:03:45 +0200 Subject: [PATCH] feat(scala): add Scala language provider and all extractors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gitnexus/package-lock.json | 20 +++++ gitnexus/package.json | 1 + .../call-extractors/configs/scala.ts | 6 ++ .../class-extractors/configs/scala.ts | 33 ++++++++ .../src/core/ingestion/export-detection.ts | 23 +++++ .../field-extractors/configs/scala.ts | 42 ++++++++++ .../heritage-extractors/configs/scala.ts | 6 ++ .../import-resolvers/configs/scala.ts | 47 +++++++++++ .../core/ingestion/import-resolvers/utils.ts | 3 + .../src/core/ingestion/languages/index.ts | 2 + .../src/core/ingestion/languages/scala.ts | 71 ++++++++++++++++ .../method-extractors/configs/scala.ts | 83 +++++++++++++++++++ .../src/core/ingestion/tree-sitter-queries.ts | 57 +++++++++++++ .../core/ingestion/type-extractors/scala.ts | 68 +++++++++++++++ .../src/core/ingestion/utils/ast-helpers.ts | 9 ++ .../variable-extractors/configs/scala.ts | 49 +++++++++++ .../src/core/tree-sitter/parser-loader.ts | 5 ++ 17 files changed, 525 insertions(+) create mode 100644 gitnexus/src/core/ingestion/call-extractors/configs/scala.ts create mode 100644 gitnexus/src/core/ingestion/class-extractors/configs/scala.ts create mode 100644 gitnexus/src/core/ingestion/field-extractors/configs/scala.ts create mode 100644 gitnexus/src/core/ingestion/heritage-extractors/configs/scala.ts create mode 100644 gitnexus/src/core/ingestion/import-resolvers/configs/scala.ts create mode 100644 gitnexus/src/core/ingestion/languages/scala.ts create mode 100644 gitnexus/src/core/ingestion/method-extractors/configs/scala.ts create mode 100644 gitnexus/src/core/ingestion/type-extractors/scala.ts create mode 100644 gitnexus/src/core/ingestion/variable-extractors/configs/scala.ts diff --git a/gitnexus/package-lock.json b/gitnexus/package-lock.json index 61ddca305..95595a940 100644 --- a/gitnexus/package-lock.json +++ b/gitnexus/package-lock.json @@ -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 diff --git a/gitnexus/package.json b/gitnexus/package.json index 73ebd3c6c..feb29c5e9 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -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" }, diff --git a/gitnexus/src/core/ingestion/call-extractors/configs/scala.ts b/gitnexus/src/core/ingestion/call-extractors/configs/scala.ts new file mode 100644 index 000000000..fc2e1e987 --- /dev/null +++ b/gitnexus/src/core/ingestion/call-extractors/configs/scala.ts @@ -0,0 +1,6 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { CallExtractionConfig } from '../../call-types.js'; + +export const scalaCallConfig: CallExtractionConfig = { + language: SupportedLanguages.Scala, +}; diff --git a/gitnexus/src/core/ingestion/class-extractors/configs/scala.ts b/gitnexus/src/core/ingestion/class-extractors/configs/scala.ts new file mode 100644 index 000000000..9fa005a49 --- /dev/null +++ b/gitnexus/src/core/ingestion/class-extractors/configs/scala.ts @@ -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; + } + }, +}; diff --git a/gitnexus/src/core/ingestion/export-detection.ts b/gitnexus/src/core/ingestion/export-detection.ts index 3eb1b4f07..dbdb96e9c 100644 --- a/gitnexus/src/core/ingestion/export-detection.ts +++ b/gitnexus/src/core/ingestion/export-detection.ts @@ -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; +}; diff --git a/gitnexus/src/core/ingestion/field-extractors/configs/scala.ts b/gitnexus/src/core/ingestion/field-extractors/configs/scala.ts new file mode 100644 index 000000000..39b59fe22 --- /dev/null +++ b/gitnexus/src/core/ingestion/field-extractors/configs/scala.ts @@ -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'; + }, +}; diff --git a/gitnexus/src/core/ingestion/heritage-extractors/configs/scala.ts b/gitnexus/src/core/ingestion/heritage-extractors/configs/scala.ts new file mode 100644 index 000000000..24435445f --- /dev/null +++ b/gitnexus/src/core/ingestion/heritage-extractors/configs/scala.ts @@ -0,0 +1,6 @@ +import { SupportedLanguages } from 'gitnexus-shared'; +import type { HeritageExtractionConfig } from '../../heritage-types.js'; + +export const scalaHeritageConfig: HeritageExtractionConfig = { + language: SupportedLanguages.Scala, +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/configs/scala.ts b/gitnexus/src/core/ingestion/import-resolvers/configs/scala.ts new file mode 100644 index 000000000..a407f2794 --- /dev/null +++ b/gitnexus/src/core/ingestion/import-resolvers/configs/scala.ts @@ -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)], +}; diff --git a/gitnexus/src/core/ingestion/import-resolvers/utils.ts b/gitnexus/src/core/ingestion/import-resolvers/utils.ts index 8d915eb7f..c9cef7a28 100644 --- a/gitnexus/src/core/ingestion/import-resolvers/utils.ts +++ b/gitnexus/src/core/ingestion/import-resolvers/utils.ts @@ -47,6 +47,9 @@ export const EXTENSIONS = [ '.swift', // Ruby '.rb', + // Scala + '.scala', + '.sc', ]; /** diff --git a/gitnexus/src/core/ingestion/languages/index.ts b/gitnexus/src/core/ingestion/languages/index.ts index 041af775d..ab50b892d 100644 --- a/gitnexus/src/core/ingestion/languages/index.ts +++ b/gitnexus/src/core/ingestion/languages/index.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/scala.ts b/gitnexus/src/core/ingestion/languages/scala.ts new file mode 100644 index 000000000..3b3490dff --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/scala.ts @@ -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 = 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, +}); diff --git a/gitnexus/src/core/ingestion/method-extractors/configs/scala.ts b/gitnexus/src/core/ingestion/method-extractors/configs/scala.ts new file mode 100644 index 000000000..9092e8de4 --- /dev/null +++ b/gitnexus/src/core/ingestion/method-extractors/configs/scala.ts @@ -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; + }, +}; diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index 61030ff66..104cecb8f 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -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 = { @@ -1390,6 +1446,7 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.Ruby]: RUBY_QUERIES, [SupportedLanguages.Swift]: SWIFT_QUERIES, [SupportedLanguages.Dart]: DART_QUERIES, + [SupportedLanguages.Scala]: SCALA_QUERIES, [SupportedLanguages.Vue]: TYPESCRIPT_QUERIES, // Vue