Skip to content

Node methods that calculate source positions are inconsistent if there are AST mutations #1979

@romainmenke

Description

@romainmenke

Context:


Each method that calculates these positions (positionInside, positionBy, rangeBy, ...) starts by taking the source start position:

let column = this.source.start.column

This is the position of the current node as found in the original source, not the current position of serializing the entire AST.

However each function uses this.toString() to scan for new lines or to find the index of a given word:

let string = stringRepresentation || this.toString()

This leads to inconsistencies and bugs when making mutations to the AST.


A minimal reproduction:

a {
	one: 1;
	two: 2;
}
  • a.positionInside(15) -> { column: 3, line: 3 }
  • two.positionInside(1) -> { column: 3, line: 3 }

If we remove one: 1; we alter the string representation of a { ... }, but not that of two: 2;.

If we then calculate the same positions we get different and inconsistent outcomes:

  • a.positionInside(15) -> { column: 12, line: 2 }
  • two.positionInside(1) -> { column: 3, line: 3 }

a.positionInside(15) reflects the position after mutation.
While two.positionInside(1) reflects the position in the original source.

I found that any positions calculated without making doing mutations are reliable and usable. But once you start changing or removing AST nodes these functions become unpredictable.

test('positionInside() returns original source position even after remove the previous node', () => {
  let css = parse('a {\n\tone: 1;\n\ttwo: 2;}')
  let a = css.first as Rule
  let one = a.first as Declaration
  let two = one.next() as Declaration

  equal(a.positionInside(15), { column: 3, line: 3 })
  equal(two.positionInside(1), { column: 3, line: 3 })

  one.remove()

  equal(a.positionInside(15), { column: 3, line: 3 }) // fails, actual value is `{ column: 12, line: 2 }`
  equal(two.positionInside(1), { column: 3, line: 3 })
})

I encountered this issue while working on Stylelint and found that using this.source.input.css instead of this.toString() gives the expected result.

I hope that a fix can be considered here because these API's were added with Stylelint in mind and the current behavior is inconsistent.

Happy to provide a PR with a proposed fix.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions