Verified Commit aee3700d authored by Michael Angelo Rivera's avatar Michael Angelo Rivera Committed by GitLab
Browse files

fix(indexer): capture Ruby module imports

parent f4db093d
Loading
Loading
Loading
Loading
+2 −2
Original line number Diff line number Diff line
# config/ontology/edges/extends.yaml

description: Supertype relationships in source code (inheritance, interface implementation, struct embedding)
description: Supertype relationships in source code (inheritance, interface implementation, struct embedding, mixin composition)
table: gl_code_edge

variants:
  - from_node: { type: Definition, id: id }
    to_node: { type: Definition, id: id }
    description: "Definition declares a supertype relationship to another definition. Covers class extension, interface implementation, and struct embedding."
    description: "Definition declares a supertype relationship to another definition. Covers class extension, interface implementation, struct embedding, and mixin composition."
+61 −28
Original line number Diff line number Diff line
@@ -475,34 +475,25 @@ fn ruby_extract_imports(node: &N<'_>, imports: &mut Vec<CanonicalImport>) -> boo
    if node.kind().as_ref() != "call" {
        return false;
    }

    let method = match node.field("method") {
        Some(m) => m.text().to_string(),
        None => return false,
    };

    if method != "require" && method != "require_relative" {
    let Some(method) = node.field("method").map(|m| m.text().to_string()) else {
        return false;
    }

    let arg = node
        .field("arguments")
        .and_then(|args| args.find(Child, Kind("string")))
        .and_then(|s| s.find(Child, Kind("string_content")))
        .map(|c| c.text().to_string());

    let Some(path) = arg else {
    };
    match method.as_str() {
        "require" | "require_relative" => {
            let Some(args) = node.field("arguments") else {
                return true;
            };

    let import_type = if method == "require_relative" {
            let path = args
                .find(Child, Kind("string"))
                .and_then(|s| s.find(Child, Kind("string_content")))
                .map(|c| c.text().to_string());
            if let Some(path) = path {
                imports.push(CanonicalImport {
                    import_type: if method == "require_relative" {
                        "RequireRelative"
                    } else {
                        "Require"
    };

    imports.push(CanonicalImport {
        import_type,
                    },
                    binding_kind: ImportBindingKind::SideEffect,
                    mode: ImportMode::Runtime,
                    path,
@@ -513,9 +504,51 @@ fn ruby_extract_imports(node: &N<'_>, imports: &mut Vec<CanonicalImport>) -> boo
                    is_type_only: false,
                    wildcard: false,
                });

            }
            true
        }
        "include" | "extend" | "prepend" => {
            let Some(args) = node.field("arguments") else {
                return true;
            };
            let import_type = match method.as_str() {
                "include" => "Include",
                "extend" => "Extend",
                _ => "Prepend",
            };
            for arg in args.children() {
                if !matches!(arg.kind().as_ref(), "constant" | "scope_resolution") {
                    continue;
                }
                push_named_import(imports, import_type, arg.text().to_string());
            }
            true
        }
        _ => false,
    }
}

fn push_named_import(imports: &mut Vec<CanonicalImport>, import_type: &'static str, fqn: String) {
    if fqn.is_empty() {
        return;
    }
    let (path, leaf) = fqn
        .rsplit_once("::")
        .map(|(p, l)| (p.to_string(), l.to_string()))
        .unwrap_or((String::new(), fqn));
    imports.push(CanonicalImport {
        import_type,
        binding_kind: ImportBindingKind::Named,
        mode: ImportMode::Declarative,
        path,
        name: Some(leaf),
        alias: None,
        scope_fqn: None,
        range: crate::v2::types::Range::empty(),
        is_type_only: false,
        wildcard: false,
    });
}

fn ruby_imported_symbol_candidates(
    graph: &CodeGraph,
+95 −0
Original line number Diff line number Diff line
name: "Ruby module composition imports"

fixtures:
  - path: app/models/base_record.rb
    content: |
      class BaseRecord
      end

  - path: app/models/concerns/auditable.rb
    content: |
      module Auditable
        def audit_event
          true
        end
      end

  - path: app/models/concerns/cacheable.rb
    content: |
      module Cacheable
        def cache_key
          "cache"
        end
      end

  - path: app/models/concerns/gitlab/tracking.rb
    content: |
      module Gitlab
        module Tracking
          def track_internal_event
            true
          end
        end
      end

  - path: app/models/concerns/instrumentation.rb
    content: |
      module Instrumentation
        def measure
          true
        end
      end

  - path: app/models/report.rb
    content: |
      class Report < BaseRecord
        include Auditable, Gitlab::Tracking
        extend Cacheable
        prepend Instrumentation
      end

tests:
  - name: "include emits one ImportedSymbol per module"
    query: |
      MATCH (f:File)-[e:FileToImportedSymbol]->(s:ImportedSymbol)
      WHERE f.path = 'app/models/report.rb'
        AND s.import_type = 'Include'
      RETURN s.name AS name, s.path AS path
    assert:
      - { row_count: 2 }
      - { row: { name: "Auditable", path: "" } }
      - { row: { name: "Tracking", path: "Gitlab" } }

  - name: "extend emits a module ImportedSymbol"
    query: |
      MATCH (f:File)-[e:FileToImportedSymbol]->(s:ImportedSymbol)
      WHERE f.path = 'app/models/report.rb'
        AND s.import_type = 'Extend'
      RETURN s.name AS name, s.path AS path
    assert:
      - { row_count: 1 }
      - { row: { name: "Cacheable", path: "" } }

  - name: "prepend emits a module ImportedSymbol"
    query: |
      MATCH (f:File)-[e:FileToImportedSymbol]->(s:ImportedSymbol)
      WHERE f.path = 'app/models/report.rb'
        AND s.import_type = 'Prepend'
      RETURN s.name AS name, s.path AS path
    assert:
      - { row_count: 1 }
      - { row: { name: "Instrumentation", path: "" } }

  - name: "class links to superclass and composed modules"
    query: |
      MATCH (child:Definition)-[e:DefinitionToDefinition]->(parent:Definition)
      WHERE child.fqn = 'Report'
        AND e.edge_kind = 'Extends'
      RETURN parent.fqn AS parent
    assert:
      - { row_count: 5 }
      - { row: { parent: "BaseRecord" } }
      - { row: { parent: "Auditable" } }
      - { row: { parent: "Gitlab::Tracking" } }
      - { row: { parent: "Cacheable" } }
      - { row: { parent: "Instrumentation" } }
+1 −0
Original line number Diff line number Diff line
@@ -272,6 +272,7 @@ yaml_test!(ruby_v1_resolution, "ruby/v1_resolution.yaml");
yaml_test!(ruby_metaprogramming, "ruby/metaprogramming.yaml");
yaml_test!(ruby_gitlab_monolith, "ruby/gitlab_monolith.yaml");
yaml_test!(ruby_e2e_weather_app, "ruby/e2e_weather_app.yaml");
yaml_test!(ruby_module_imports, "ruby/module_imports.yaml");
yaml_test!(
    ruby_imported_symbol_fallback_matrix,
    "ruby/imported_symbol_fallback_matrix.yaml"