diff --git a/README.md b/README.md index b310e8d..6c19afa 100644 --- a/README.md +++ b/README.md @@ -213,23 +213,34 @@ Oxc.parse("let a; let a;", semantic_errors: true).errors.map(&:message) root = Oxc.parse(source).root root.type #=> "Program" +root.child_nodes #=> the nodes directly under it root.every("Identifier") #=> every identifier in the file root.at(offset) #=> the innermost node covering a byte offset root.each #=> an Enumerator over every node ``` +An ESTree field always wins over a method of the gem's own, since `name`, `attributes` and `children` are all real fields. `Identifier#name` is the identifier's name, `JSXElement#children` is what the element wraps, and `ImportDeclaration#attributes` is the import's `with` clause. The walker spells its own versions `underscored_type`, `to_h` and `child_nodes`, which no ESTree field can be called. + A field comes back as a node when it holds one, so reads chain. ```ruby -declaration = root.children.first +declaration = root.child_nodes.first declaration.kind #=> "let" -declaration.declarations.first.id["name"] +declaration.declarations.first.id.name #=> "count" ``` +ESTree names its fields in camelCase, and a field answers to its snake_case name too, so reading an AST does not mean writing JavaScript casing in Ruby. + +```ruby +node.type_annotation # the same field as node.typeAnnotation +node.super_class # superClass +root.source_type # sourceType +``` + `ancestors` is what a rewrite needs, since a reference sits inside the expression that has to be replaced. ```ruby diff --git a/lib/oxc/node.rb b/lib/oxc/node.rb index 967895b..d302ea1 100644 --- a/lib/oxc/node.rb +++ b/lib/oxc/node.rb @@ -9,9 +9,14 @@ class Node SPAN = ["type", "start", "end"].freeze #: Array[String] SCALARS = 3 #: Integer - attr_reader :attributes #: Hash[String, untyped] attr_reader :parent #: Oxc::Node? + protected + + attr_reader :attributes #: Hash[String, untyped] + + public + #: (Hash[String, untyped], ?Oxc::Node?) -> void def initialize(attributes, parent = nil) @attributes = attributes @@ -34,8 +39,8 @@ def finish end #: () -> String - def name - @name ||= type.gsub(ACRONYM, '\1_\2').gsub(BOUNDARY, '\1_\2').downcase + def underscored_type + @underscored_type ||= type.gsub(ACRONYM, '\1_\2').gsub(BOUNDARY, '\1_\2').downcase end #: (String) -> untyped @@ -43,18 +48,23 @@ def [](key) attributes[key] end + #: () -> Hash[String, untyped] + def to_h + attributes + end + #: (String) -> String? def slice(source) source.byteslice(start, finish - start) end #: () -> Array[Oxc::Node] - def children - @children ||= attributes.each_value - .flat_map { |value| value.is_a?(Array) ? value : [value] } - .select { |value| value.is_a?(Hash) && value.key?("type") } - .map { |value| Node.new(value, self) } - .freeze + def child_nodes + @child_nodes ||= attributes.each_value + .flat_map { |value| value.is_a?(Array) ? value : [value] } + .select { |value| value.is_a?(Hash) && value.key?("type") } + .map { |value| Node.new(value, self) } + .freeze end #: () { (Oxc::Node) -> void } -> void @@ -64,7 +74,7 @@ def each(&) yield self - children.each { |child| child.each(&) } + child_nodes.each { |child| child.each(&) } end #: () -> Array[Oxc::Node] @@ -99,20 +109,29 @@ def inspect #: (Symbol, *untyped) -> untyped def method_missing(name, *arguments) - key = name.to_s + key = field_for(name.to_s) - return super unless attributes.key?(key) + return super unless key wrap(attributes[key]) end #: (Symbol, ?bool) -> bool def respond_to_missing?(name, include_private = false) - attributes.key?(name.to_s) || super + !field_for(name.to_s).nil? || super end private + #: (String) -> String? + def field_for(name) + return name if attributes.key?(name) + + camelized = name.gsub(/_([a-z\d])/) { Regexp.last_match(1).to_s.upcase } + + camelized if attributes.key?(camelized) + end + #: (untyped) -> untyped def wrap(value) case value diff --git a/lib/oxc/visitor.rb b/lib/oxc/visitor.rb index 77463be..c9773cc 100644 --- a/lib/oxc/visitor.rb +++ b/lib/oxc/visitor.rb @@ -8,7 +8,7 @@ def visit(node) return nil unless node - answer = "visit_#{node.name}" + answer = "visit_#{node.underscored_type}" respond_to?(answer) ? public_send(answer, node) : visit_children(node) @@ -17,7 +17,7 @@ def visit(node) #: (Oxc::Node) -> void def visit_children(node) - node.children.each { |child| visit(child) } + node.child_nodes.each { |child| visit(child) } nil end diff --git a/sig/oxc/node.rbs b/sig/oxc/node.rbs index 551b504..cd52bb0 100644 --- a/sig/oxc/node.rbs +++ b/sig/oxc/node.rbs @@ -12,9 +12,11 @@ module Oxc SCALARS: Integer + attr_reader parent: Oxc::Node? + attr_reader attributes: Hash[String, untyped] - attr_reader parent: Oxc::Node? + public # : (Hash[String, untyped], ?Oxc::Node?) -> void def initialize: (Hash[String, untyped], ?Oxc::Node?) -> void @@ -29,16 +31,19 @@ module Oxc def finish: () -> Integer # : () -> String - def name: () -> String + def underscored_type: () -> String # : (String) -> untyped def []: (String) -> untyped + # : () -> Hash[String, untyped] + def to_h: () -> Hash[String, untyped] + # : (String) -> String? def slice: (String) -> String? # : () -> Array[Oxc::Node] - def children: () -> Array[Oxc::Node] + def child_nodes: () -> Array[Oxc::Node] # : () { (Oxc::Node) -> void } -> void # : () -> Enumerator[Oxc::Node, void] @@ -68,6 +73,9 @@ module Oxc private + # : (String) -> String? + def field_for: (String) -> String? + # : (untyped) -> untyped def wrap: (untyped) -> untyped end diff --git a/test/oxc/node_test.rb b/test/oxc/node_test.rb index c6c4320..bfc9e63 100644 --- a/test/oxc/node_test.rb +++ b/test/oxc/node_test.rb @@ -21,13 +21,13 @@ def root end test "names itself the way a visitor answers it" do - assert_equal "program", root.name - assert_equal "variable_declaration", root.children.first.name + assert_equal "program", root.underscored_type + assert_equal "variable_declaration", root.child_nodes.first.underscored_type end test "reads an acronym as one word" do - assert_equal "ts_type_annotation", Node.new({ "type" => "TSTypeAnnotation" }).name - assert_equal "jsx_element", Node.new({ "type" => "JSXElement" }).name + assert_equal "ts_type_annotation", Node.new({ "type" => "TSTypeAnnotation" }).underscored_type + assert_equal "jsx_element", Node.new({ "type" => "JSXElement" }).underscored_type end test "walks everything under it" do @@ -42,7 +42,7 @@ def root end test "reads a field as the node it holds" do - declarator = root.children.first.declarations.first + declarator = root.child_nodes.first.declarations.first assert_equal "Identifier", declarator.id.type assert_equal "count", declarator.id["name"] @@ -50,7 +50,64 @@ def root end test "reads a field that is not a node as it is" do - assert_equal "let", root.children.first.kind + assert_equal "let", root.child_nodes.first.kind + end + + test "answers the ESTree field and not a method of its own" do + identifier = root.every("Identifier").first + + assert_equal "count", identifier.name + assert_equal "identifier", identifier.underscored_type + end + + test "answers the ESTree field on a node carrying children of its own" do + element = Oxc.parse("
", filename: "App.jsx").root.every("JSXElement").first + + assert_equal ["JSXElement"], element.children.map(&:type) + assert_equal ["JSXOpeningElement", "JSXElement", "JSXClosingElement"], element.child_nodes.map(&:type) + end + + test "answers the ESTree field on a node carrying attributes of its own" do + opening = Oxc.parse(%(
), filename: "App.jsx").root.every("JSXOpeningElement").first + + assert_equal(["id"], opening.attributes.map { |attribute| attribute.name.name }) + end + + test "answers import attributes, which name a field the walker once shadowed" do + source = %(import a from "./a" with { type: "json" }) + declaration = Oxc.parse(source, source_type: "module").root.child_nodes.first + + assert_equal(["type"], declaration.attributes.map { |attribute| attribute.key.name }) + end + + test "reads a camelCase field by its snake_case name" do + source = %(class C extends B { declare readonly x?: T }) + declaration = Oxc.parse(source, filename: "a.ts").root.every("PropertyDefinition").first + + assert_equal "TSTypeAnnotation", declaration.type_annotation.type + assert_equal declaration.typeAnnotation.type, declaration.type_annotation.type + assert_equal "B", Oxc.parse(source, filename: "a.ts").root.every("ClassDeclaration").first.super_class.name + assert_equal "module", root.source_type + end + + test "answers to a field by either name" do + declaration = Oxc.parse("class C { x?: T }", filename: "a.ts").root.every("PropertyDefinition").first + + assert_respond_to declaration, :type_annotation + assert_respond_to declaration, :typeAnnotation + refute_respond_to declaration, :type_nonsense + end + + test "leaves a method of its own alone, whatever a field is called" do + assert_equal "program", root.underscored_type + assert_equal 2, root.child_nodes.length + end + + test "answers its own fields as a hash" do + node = root.every("VariableDeclarator").first + + assert_equal ["type", "id", "init", "start", "end"], node.to_h.keys + assert_equal "VariableDeclarator", node.to_h["type"] end test "refuses a field it does not carry" do @@ -90,11 +147,11 @@ def root test "prints what it is, and what it carries in its own right" do assert_equal %(#), root.inspect assert_equal %(#), root.every("Identifier").first.inspect - assert_equal %(#), root.children.first.inspect + assert_equal %(#), root.child_nodes.first.inspect end test "leaves the nodes hanging off it out of what it carries" do - declaration = root.children.first + declaration = root.child_nodes.first assert_equal ["kind"], declaration.scalars.keys end