Class: Ibex::LSP::WorkspaceAnalyzer

Inherits:
Object
  • Object
show all
Defined in:
lib/ibex/lsp/workspace_analyzer.rb,
sig/ibex/lsp/workspace_analyzer.rbs

Overview

Parses workspace sources, discovers safe include closures, and renders diagnostics.

Constant Summary collapse

GLOB_CHARACTERS =

Signature:

  • Regexp

Returns:

  • (Regexp)
/[*?\[\]{}]/
WINDOWS_ABSOLUTE =

Signature:

  • Regexp

Returns:

  • (Regexp)
%r{\A(?:[A-Za-z]:[\\/]|\\\\)}

Instance Method Summary collapse

Constructor Details

#initialize(workspace, loader) ⇒ WorkspaceAnalyzer

Returns a new instance of WorkspaceAnalyzer.

RBS:

  • (Workspace workspace, Frontend::SourceLoader loader) -> void

Parameters:



19
20
21
22
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 19

def initialize(workspace, loader)
  @workspace = workspace
  @loader = loader
end

Instance Method Details

#analyze(path) ⇒ analyzed_document

RBS:

  • (String path) -> analyzed_document

Parameters:

  • path (String)

Returns:

  • (analyzed_document)


25
26
27
28
29
30
31
32
33
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 25

def analyze(path)
  source = @loader.read(path)
  return analyze_fragment(path, source) if fragment_source?(source, path)

  analyze_root(path, source)
rescue Ibex::Error, SystemCallError => e
  source ||= readable_source(path)
  { source: source, document: nil, diagnostics: [diagnostic(e.message, source, path)] }
end

#analyze_fragment(path, source) ⇒ analyzed_document

RBS:

  • (String path, String source) -> analyzed_document

Parameters:

  • path (String)
  • source (String)

Returns:

  • (analyzed_document)


74
75
76
77
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 74

def analyze_fragment(path, source)
  document = Frontend::Parser.new(source, file: path, mode: :extended).parse_source_document
  { source: source, document: document, diagnostics: [] }
end

#analyze_root(path, source) ⇒ analyzed_document

RBS:

  • (String path, String source) -> analyzed_document

Parameters:

  • path (String)
  • source (String)

Returns:

  • (analyzed_document)


66
67
68
69
70
71
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 66

def analyze_root(path, source)
  result = Frontend::Parser.new(source, file: path, mode: :extended)
                           .parse_with_diagnostics(max_diagnostics: 20)
  diagnostics = result.diagnostics.map { |entry| frontend_diagnostic(entry, source) }
  { source: source, document: result.document, diagnostics: diagnostics }
end

#canonical_include(include_node) ⇒ String

RBS:

  • (Frontend::AST::Include include_node) -> String

Parameters:

Returns:

  • (String)


103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 103

def canonical_include(include_node)
  path = include_node.path
  return invalid_include(include_node) if path.empty? || path.include?("\0")
  return invalid_include(include_node) if path.start_with?("/") || path.match?(WINDOWS_ABSOLUTE)
  return invalid_include(include_node) if path.split(%r{[\\/]}).include?("..")
  return invalid_include(include_node) if path.match?(GLOB_CHARACTERS)

  candidate = File.expand_path(path, File.dirname(include_node.loc.file))
  canonical = @loader.canonical_path(candidate, allow_missing: true)
  return canonical if @workspace.root_for(canonical)

  invalid_include(include_node)
end

#closure(root) ⇒ [ Array[String], Hash[String, analyzed_document] ]

RBS:

  • (String root) -> [Array[String], Hash[String, analyzed_document]]

Parameters:

  • root (String)

Returns:

  • ([ Array[String], Hash[String, analyzed_document] ])


36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 36

def closure(root)
  files = [] #: Array[String]
  analyzed = {} #: Hash[String, analyzed_document]
  visiting = [root]
  until visiting.empty?
    path = visiting.pop
    next unless path
    next if analyzed.key?(path)

    current = analyze(path)
    analyzed[path] = current
    files << path
    dependencies(current.fetch(:document)).reverse_each do |target|
      visiting << target unless analyzed.key?(target)
    end
  end
  [files, analyzed]
end

#dependencies(document) ⇒ Array[String]

RBS:

  • (Frontend::SourceDocument? document) -> Array[String]

Parameters:

Returns:

  • (Array[String])


89
90
91
92
93
94
95
96
97
98
99
100
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 89

def dependencies(document)
  ast = document&.ast
  return [] unless ast

  ast.declarations.filter_map do |declaration|
    next unless declaration.is_a?(Frontend::AST::Include)

    canonical_include(declaration)
  rescue Ibex::Error, SystemCallError
    nil
  end
end

#diagnostic(message, source, fallback_path) ⇒ lsp_diagnostic

RBS:

  • (String message, String source, String fallback_path) -> lsp_diagnostic

Parameters:

  • message (String)
  • source (String)
  • fallback_path (String)

Returns:

  • (lsp_diagnostic)


123
124
125
126
127
128
129
130
131
132
133
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 123

def diagnostic(message, source, fallback_path)
  file, line, column, text = diagnostic_parts(message, fallback_path)
  {
    "range" => diagnostic_range(source, line, column),
    "severity" => 1,
    "code" => "ibex.frontend",
    "source" => "ibex",
    "message" => text,
    "data" => { "file" => file }
  }
end

#diagnostic_parts(message, fallback_path) ⇒ [ String, Integer, Integer, String ]

RBS:

  • (String message, String fallback_path) -> [String, Integer, Integer, String]

Parameters:

  • message (String)
  • fallback_path (String)

Returns:

  • ([ String, Integer, Integer, String ])


162
163
164
165
166
167
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 162

def diagnostic_parts(message, fallback_path)
  match = message.match(/\A(.*):(\d+):(\d+):\s*(.*)\z/m)
  return [fallback_path, 1, 1, message] unless match

  [match[1] || fallback_path, Integer(match[2]), Integer(match[3]), match[4] || message]
end

#diagnostic_range(source, line, column) ⇒ Hash[String, Hash[String, Integer]]

RBS:

  • (String source, Integer line, Integer column) -> Hash[String, Hash[String, Integer]]

Parameters:

  • source (String)
  • line (Integer)
  • column (Integer)

Returns:

  • (Hash[String, Hash[String, Integer]])


170
171
172
173
174
175
176
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 170

def diagnostic_range(source, line, column)
  line_text = source.lines.fetch(line - 1, "").delete_suffix("\n").delete_suffix("\r")
  prefix = line_text.each_char.take([column - 1, 0].max).join
  character = prefix.each_codepoint.sum { |codepoint| codepoint > 0xFFFF ? 2 : 1 }
  point = { "line" => [line - 1, 0].max, "character" => character }
  { "start" => point, "end" => point.dup }
end

#file_for_error(message, fallback) ⇒ String

RBS:

  • (String message, String fallback) -> String

Parameters:

  • message (String)
  • fallback (String)

Returns:

  • (String)


186
187
188
189
190
191
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 186

def file_for_error(message, fallback)
  match = message.match(/\A(.*):\d+:\d+:/)
  return fallback unless match

  match[1] || fallback
end

#fragment_source?(source, path) ⇒ Boolean

RBS:

  • (String source, String path) -> bool

Parameters:

  • source (String)
  • path (String)

Returns:

  • (Boolean)


80
81
82
83
84
85
86
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 80

def fragment_source?(source, path)
  document, = Frontend::Lexer.new(source, file: path).tokenize_document_recovering(max_diagnostics: 1)
  first = document.tokens.find { |token| token.type != :eof }
  first&.value == "fragment"
rescue Ibex::Error
  false
end

#frontend_diagnostic(diagnostic, source) ⇒ lsp_diagnostic

RBS:

  • (Frontend::Diagnostic diagnostic, String source) -> lsp_diagnostic

Parameters:

Returns:

  • (lsp_diagnostic)


136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 136

def frontend_diagnostic(diagnostic, source)
  range = if diagnostic.span
            PositionCodec.new(source).range(diagnostic.span)
          else
            diagnostic_range(source, diagnostic.location.line, diagnostic.location.column)
          end
  {
    "range" => range,
    "severity" => 1,
    "code" => diagnostic.code,
    "source" => "ibex",
    "message" => diagnostic.message,
    "data" => { "file" => diagnostic.location.file }
  }
rescue ArgumentError, Ibex::Error
  {
    "range" => diagnostic_range("", 1, 1),
    "severity" => 1,
    "code" => diagnostic.code,
    "source" => "ibex",
    "message" => diagnostic.message,
    "data" => { "file" => diagnostic.location.file }
  }
end

#invalid_include(include_node) ⇒ bot

RBS:

  • (Frontend::AST::Include include_node) -> bot

Parameters:

Returns:

  • (bot)


118
119
120
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 118

def invalid_include(include_node)
  raise Ibex::Error, "#{include_node.loc}: unsafe include path"
end

#readable_source(path) ⇒ String

RBS:

  • (String path) -> String

Parameters:

  • path (String)

Returns:

  • (String)


179
180
181
182
183
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 179

def readable_source(path)
  @loader.read(path)
rescue SystemCallError
  ""
end

#resolve(root) ⇒ [ Frontend::Resolution?, Array[lsp_diagnostic] ]

RBS:

  • (String root) -> [Frontend::Resolution?, Array[lsp_diagnostic]]

Parameters:

  • root (String)

Returns:



56
57
58
59
60
61
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 56

def resolve(root)
  resolution = Frontend::Resolver.new(root, mode: :extended, loader: @loader).resolve
  [resolution, []]
rescue Ibex::Error, SystemCallError => e
  [nil, [diagnostic(e.message, source_for_error(e.message, root), file_for_error(e.message, root))]]
end

#source_for_error(message, fallback) ⇒ String

RBS:

  • (String message, String fallback) -> String

Parameters:

  • message (String)
  • fallback (String)

Returns:

  • (String)


194
195
196
# File 'lib/ibex/lsp/workspace_analyzer.rb', line 194

def source_for_error(message, fallback)
  readable_source(file_for_error(message, fallback))
end