Skip to content

Commit

Permalink
feat(manifest): preserve comments in JSON5 manifests
Browse files Browse the repository at this point in the history
  Use npm package `strip-comments-strings` to find all comments in any
  manifest file as it is read. Save them as part of the "file formatting"
  detected byt the manifest reader, noting for each the text of the lines
  they are on, before, and after, and the line number as a fallback.

  When the manifest is written, attempt to place each comment back in
  the resulting JSON5 text, so that the text of the line it is on, before,
  or after (in that priority order) matches the text at time of reading.
  Otherwise, so that no comments are lost, replace the comment on the same
  line number, adding a notation that it may have been relocated (due to
  sorting dependencies, for example, it may no longer be in the same "logical
  position" in the file, even though it is on the same physical line number).

  When comments are in fairly ordinary positions and the manifest does not
  change too drastically (i.e. once the dependencies are sorted as pnpm
  prefers, and there are not many parameters added all at once), this strategy
  results in exact preservation of the comments, as a new test shows.

  The motivation is that one key feature of JSON5 is that it allows human-
  readable comments, but for this feature to be useful in the case of
  package.json5 manifests, those comments must be preserved across manifest
  changes.

  Partially resolves pnpm#2008. [That issue requests this feature also for YAML
  manifests, but I have no experience working with YAML, and it also
  requests that key order is preserved, but I did not address this because
  current code in the the pnpm manifest reader/writer _explicitly_ reorders
  keys -- clearly quite deliberately -- so I did not want to simply remove
  code that appeared to have been purposefully.]
  • Loading branch information
gwhitney committed Sep 2, 2022
1 parent 47140dc commit 152e548
Show file tree
Hide file tree
Showing 8 changed files with 195 additions and 5 deletions.
@@ -0,0 +1,9 @@
/* This is an example of a package.json5 file with comments. */
{
/* pnpm should keep comments at the same indentation level */
name: 'foo',
version: '1.0.0', // it should keep in-line comments on the same line
// It should allow in-line comments with no other content
type: 'commonjs',
}
/* And it should preserve comments at the end of the file. Note no newline. */
@@ -0,0 +1,9 @@
/* This is an example of a package.json5 file with comments. */
{
/* pnpm should keep comments at the same indentation level */
name: 'foo',
version: '1.0.0', // it should keep in-line comments on the same line
// It should allow in-line comments with no other content
type: 'module',
}
/* And it should preserve comments at the end of the file. Note no newline. */
3 changes: 2 additions & 1 deletion packages/read-project-manifest/package.json
Expand Up @@ -40,7 +40,8 @@
"parse-json": "^5.2.0",
"read-yaml-file": "^2.1.0",
"sort-keys": "^4.2.0",
"strip-bom": "^4.0.0"
"strip-bom": "^4.0.0",
"strip-comments-strings": "1.2.0"
},
"devDependencies": {
"@pnpm/read-project-manifest": "workspace:*",
Expand Down
53 changes: 51 additions & 2 deletions packages/read-project-manifest/src/index.ts
Expand Up @@ -2,10 +2,11 @@ import { promises as fs, Stats } from 'fs'
import path from 'path'
import PnpmError from '@pnpm/error'
import { ProjectManifest } from '@pnpm/types'
import writeProjectManifest from '@pnpm/write-project-manifest'
import { writeProjectManifest, CommentSpecifier } from '@pnpm/write-project-manifest'
import readYamlFile from 'read-yaml-file'

import detectIndent from 'detect-indent'
import { parseString, stripComments } from 'strip-comments-strings'
import equal from 'fast-deep-equal'
import isWindows from 'is-windows'
import sortKeys from 'sort-keys'
Expand Down Expand Up @@ -118,9 +119,55 @@ export async function tryReadProjectManifest (projectDir: string): Promise<{
}

function detectFileFormatting (text: string) {
const finalNewline = text.endsWith('\n')
if (!finalNewline) {
/* For the sake of the comment parser, which otherwise loses the
* final character of a final comment
*/
text += '\n'
}
const { comments } = parseString(text)
let stripped = stripComments(text)
if (!finalNewline) {
stripped = stripped.slice(0, -1)
}
let offset = 0 // accumulates difference of indices from text to stripped
for (const comment of comments) {
// Unfortunately, JavaScript lastIndexOf does not have an end parameter:
const preamble: string = stripped.slice(0, comment.index - offset)
const lineStart = Math.max(preamble.lastIndexOf('\n'), 0)
const priorLines = preamble.split('\n')
comment.lineNumber = priorLines.length
if (comment.lineNumber === 1) {
if (preamble.trim().length === 0) {
comment.lineNumber = 0
}
} else {
comment.after = priorLines[comment.lineNumber - 2]
if (priorLines[0].trim().length === 0) {
/* JSON5.stringify will not have a whitespace-only line at the start */
comment.lineNumber -= 1
}
}
let lineEnd: number = stripped.indexOf(
'\n', (lineStart === 0) ? 0 : lineStart + 1)
if (lineEnd < 0) {
lineEnd = stripped.length
}
comment.on = stripped.slice(lineStart, lineEnd)
comment.whitespace = stripped
.slice(lineStart, comment.index - offset)
.match(/^\s*/)[0]
const nextLineEnd = stripped.indexOf('\n', lineEnd + 1)
if (nextLineEnd >= 0) {
comment.before = stripped.slice(lineEnd, nextLineEnd)
}
offset += comment.indexEnd - comment.index
}
return {
comments,
indent: detectIndent(text).indent,
insertFinalNewline: text.endsWith('\n'),
insertFinalNewline: finalNewline,
}
}

Expand Down Expand Up @@ -174,6 +221,7 @@ async function readPackageYaml (filePath: string) {
function createManifestWriter (
opts: {
initialManifest: ProjectManifest
comments?: CommentSpecifier[]
indent?: string | number | undefined
insertFinalNewline?: boolean
manifestPath: string
Expand All @@ -184,6 +232,7 @@ function createManifestWriter (
updatedManifest = normalize(updatedManifest)
if (force === true || !equal(initialManifest, updatedManifest)) {
await writeProjectManifest(opts.manifestPath, updatedManifest, {
comments: opts.comments,
indent: opts.indent,
insertFinalNewline: opts.insertFinalNewline,
})
Expand Down
@@ -0,0 +1,2 @@
// strip-comments-strings/index.d.ts
declare module 'strip-comments-strings';
20 changes: 20 additions & 0 deletions packages/read-project-manifest/test/index.ts
Expand Up @@ -82,6 +82,26 @@ test('preserve space indentation in json5 file', async () => {
expect(rawManifest).toBe("{\n name: 'foo',\n dependencies: {\n bar: '1.0.0',\n },\n}\n")
})

test('preserve comments in json5 file', async () => {
const originalManifest = await fs.readFile(
path.join(fixtures, 'commented-package-json5/package.json5'), 'utf8')
const modifiedManifest = await fs.readFile(
path.join(fixtures, 'commented-package-json5/modified.json5'), 'utf8')

process.chdir(tempy.directory())
await fs.writeFile('package.json5', originalManifest, 'utf8')

const { manifest, writeProjectManifest } = await readProjectManifest(process.cwd())

// Have to make a change to get it to write anything:
const newManifest = Object.assign({}, manifest, { type: 'commonjs' })

await writeProjectManifest(newManifest)

const resultingManifest = await fs.readFile('package.json5', 'utf8')
expect(resultingManifest).toBe(modifiedManifest)
})

test('do not save manifest if it had no changes', async () => {
process.chdir(tempy.directory())

Expand Down
97 changes: 95 additions & 2 deletions packages/write-project-manifest/src/index.ts
Expand Up @@ -10,10 +10,21 @@ const YAML_FORMAT = {
noRefs: true,
}

export default async function writeProjectManifest (
export interface CommentSpecifier {
type: string
content: string
lineNumber: number
after?: string
on: string
whitespace: string
before?: string
}

export async function writeProjectManifest (
filePath: string,
manifest: ProjectManifest,
opts?: {
comments?: CommentSpecifier[]
indent?: string | number | undefined
insertFinalNewline?: boolean
}
Expand All @@ -26,8 +37,90 @@ export default async function writeProjectManifest (
await fs.mkdir(path.dirname(filePath), { recursive: true })
const trailingNewline = opts?.insertFinalNewline === false ? '' : '\n'

const json = (fileType === 'json5' ? JSON5 : JSON)
let json = (fileType === 'json5' ? JSON5 : JSON)
.stringify(manifest, undefined, opts?.indent ?? '\t')

if (opts?.comments) {
// We need to reintroduce the comments. So create an index of
// the lines of the manifest so we can try to match them up.
// We eliminate whitespace and quotes because pnpm may have changed them.
const jsonLines = json.split('\n')
const index = {}
for (let i = 0; i < jsonLines.length; ++i) {
const key = jsonLines[i].replace(/[\s'"]/g, '')
if (key in index) {
index[key] = -1
} else {
index[key] = i
}
}

// A place to put comments that come _before_ the lines they are
// anchored to:
const jsonPrefix: Record<string, string> = {}
for (const comment of opts.comments) {
// First if we can find the line the comment was on, that is
// the most reliable locator:
let key = comment.on.replace(/[\s'"]/g, '')
if (key && index[key] !== undefined && index[key] >= 0) {
jsonLines[index[key]] += ' ' + comment.content
continue
}
// Next, if it's not before anything, it must have been at the very end:
if (comment.before === undefined) {
jsonLines[jsonLines.length - 1] += comment.whitespace + comment.content
continue
}
// Next, try to put it before something; note the comment extractor
// used the convention that position 0 is before the first line:
let location = (comment.lineNumber === 0) ? 0 : -1
if (location < 0) {
key = comment.before.replace(/[\s'"]/g, '')
if (key && index[key] !== undefined) {
location = index[key]
}
}
if (location >= 0) {
if (jsonPrefix[location]) {
jsonPrefix[location] += ' ' + comment.content
} else {
const inlineWhitespace = comment.whitespace.startsWith('\n')
? comment.whitespace.slice(1)
: comment.whitespace
jsonPrefix[location] = inlineWhitespace + comment.content
}
continue
}
// The last definite indicator we can use is that it is after something:
if (comment.after) {
key = comment.after.replace(/[\s'"]/g, '')
if (key && index[key] !== undefined && index[key] >= 0) {
jsonLines[index[key]] += comment.whitespace + comment.content
continue
}
}
// Finally, try to get it in the right general location by using the
// line number, but warn the user the comment may have been relocated:
location = comment.lineNumber - 1 // 0 was handled above
let separator = ' '
if (location >= jsonLines.length) {
location = jsonLines.length - 1
separator = '\n'
}
jsonLines[location] += separator + comment.content +
' /* [comment possibly relocated by pnpm] */'
}
// Insert the accumulated prefixes:
for (let i = 0; i < jsonLines.length; ++i) {
if (jsonPrefix[i]) {
jsonLines[i] = jsonPrefix[i] + '\n' + jsonLines[i]
}
}
// And reassemble the manifest:
json = jsonLines.join('\n')
}

return writeFileAtomic(filePath, `${json}${trailingNewline}`)
}

export default writeProjectManifest
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 152e548

Please sign in to comment.