Repository: stoplightio/spectral (ts)

Issue:
Accept input via stdin
Accept stdin as an alternative way to get a document, so that folks can use spectral in the middle of complex workflows without needing to write temporary files. 

```bash
cat openapi.yml | spectral -r some-ruleset.yml
```

This was mention by @radicarl in https://github.com/stoplightio/spectral/issues/656#issuecomment-550960358 but has been mentioned a few times in person and elsewhere. Just kept forgetting to write it down.

Pull request description:
feat(cli): accept stdin
Closes #757

**Checklist**

- [x] Tests added / updated
- [ ] Docs added / updated

**Does this PR introduce a breaking change?**

- [ ] Yes
- [x] No

**Additional context**

~Added isTTY env variable.
https://github.com/stoplightio/spectral/tree/chore/test-harness work has been pushed here. Still need to handle tty correctly, but it's getting closed.~


Patch:
diff --git a/src/cli/commands/lint.ts b/src/cli/commands/lint.ts
index d65c58a3..d05c40e1 100644
--- a/src/cli/commands/lint.ts
+++ b/src/cli/commands/lint.ts
@@ -1,7 +1,8 @@
 import { Dictionary } from '@stoplight/types';
+import { pick } from 'lodash';
+import { ReadStream } from 'tty';
 import { CommandModule, showHelp } from 'yargs';
 
-import { pick } from 'lodash';
 import { getDiagnosticSeverity } from '../../rulesets/severity';
 import { IRuleResult } from '../../types';
 import { FailSeverity, ILintConfig, OutputFormat } from '../../types/config';
@@ -14,13 +15,28 @@ const formatOptions = Object.values(OutputFormat);
 
 const lintCommand: CommandModule = {
   describe: 'lint JSON/YAML documents from files or URLs',
-  command: 'lint <documents..>',
+  command: 'lint [documents..]',
   builder: yargs =>
     yargs
+      .strict()
       .positional('documents', {
         description:
           'Location of JSON/YAML documents. Can be either a file, a glob or fetchable resource(s) on the web.',
         type: 'string',
+        coerce(values) {
+          if (values.length > 0) {
+            return values;
+          }
+
+          // https://stackoverflow.com/questions/39801643/detect-if-node-receives-stdin
+          // https://twitter.com/MylesBorins/status/782009479382626304
+          // https://nodejs.org/dist/latest/docs/api/tty.html#tty_readstream_istty
+          if (process.stdin.isTTY) {
+            return [];
+          }
+
+          return [(process.stdin as ReadStream & { fd: 0 }).fd];
+        },
       })
       .fail(() => {
         showHelp();
@@ -30,6 +46,10 @@ const lintCommand: CommandModule = {
           return false;
         }
 
+        if (!Array.isArray(argv.documents) || argv.documents.length === 0) {
+          return false;
+        }
+
         return true;
       })
       .options({
@@ -99,7 +119,7 @@ const lintCommand: CommandModule = {
       encoding,
       ...config
     } = (args as unknown) as ILintConfig & {
-      documents: string[];
+      documents: Array<number | string>;
       failSeverity: FailSeverity;
       displayOnlyFailures: boolean;
     };
diff --git a/src/cli/services/linter/linter.ts b/src/cli/services/linter/linter.ts
index 45c0a21b..c56e34c9 100644
--- a/src/cli/services/linter/linter.ts
+++ b/src/cli/services/linter/linter.ts
@@ -30,7 +30,7 @@ const KNOWN_FORMATS: Array<[string, FormatLookup, string]> = [
   ['json-schema-2019-09', isJSONSchemaDraft2019_09, 'JSON Schema Draft 2019-09 detected'],
 ];
 
-export async function lint(documents: string[], flags: ILintConfig) {
+export async function lint(documents: Array<number | string>, flags: ILintConfig) {
   const spectral = new Spectral({ resolver: httpAndFileResolver });
 
   const ruleset = await getRuleset(flags.ruleset);
@@ -77,7 +77,7 @@ export async function lint(documents: string[], flags: ILintConfig) {
     });
 
     const parsedResult: IParsedResult = {
-      source: targetUri,
+      source: typeof targetUri === 'number' ? '<STDIN>' : targetUri,
       parsed: spec,
       getLocationForJsonPath,
     };
@@ -85,7 +85,7 @@ export async function lint(documents: string[], flags: ILintConfig) {
     results.push(
       ...(await spectral.run(parsedResult, {
         resolve: {
-          documentUri: targetUri,
+          documentUri: typeof targetUri === 'number' ? void 0 : targetUri,
         },
       })),
     );
diff --git a/src/cli/services/linter/utils/listFiles.ts b/src/cli/services/linter/utils/listFiles.ts
index a810a3cb..b490db17 100644
--- a/src/cli/services/linter/utils/listFiles.ts
+++ b/src/cli/services/linter/utils/listFiles.ts
@@ -1,10 +1,16 @@
 import { normalize } from '@stoplight/path';
 import * as fg from 'fast-glob';
 
-export async function listFiles(pattens: string[]): Promise<string[]> {
-  const { files, urls } = pattens.reduce<{ files: string[]; urls: string[] }>(
+export async function listFiles(pattens: Array<number | string>): Promise<Array<number | string>> {
+  const { files, fileDescriptors, urls } = pattens.reduce<{
+    files: string[];
+    urls: string[];
+    fileDescriptors: number[];
+  }>(
     (group, pattern) => {
-      if (!/^https?:\/\//.test(pattern)) {
+      if (typeof pattern === 'number') {
+        group.fileDescriptors.push(pattern);
+      } else if (!/^https?:\/\//.test(pattern)) {
         group.files.push(pattern.replace(/\\/g, '/'));
       } else {
         group.urls.push(pattern);
@@ -15,8 +21,9 @@ export async function listFiles(pattens: string[]): Promise<string[]> {
     {
       files: [],
       urls: [],
+      fileDescriptors: [],
     },
   );
 
-  return [...urls, ...(await fg(files, { dot: true, absolute: true })).map(normalize)]; // let's normalize OS paths produced by fast-glob to have consistent paths across all platforms
+  return [...urls, ...fileDescriptors, ...(await fg(files, { dot: true, absolute: true })).map(normalize)]; // let's normalize OS paths produced by fast-glob to have consistent paths across all platforms
 }
diff --git a/src/fs/reader.ts b/src/fs/reader.ts
index ba398a3b..bafa818c 100644
--- a/src/fs/reader.ts
+++ b/src/fs/reader.ts
@@ -9,12 +9,31 @@ export interface IReadOptions {
   timeout?: number;
 }
 
-export async function readFile(name: string, opts: IReadOptions): Promise<string> {
-  if (name in STATIC_ASSETS) {
-    return STATIC_ASSETS[name];
-  }
+export async function readFile(name: string | number, opts: IReadOptions): Promise<string> {
+  if (typeof name === 'number') {
+    let result = '';
+
+    const stream = fs.createReadStream('', { fd: name });
+    stream.setEncoding(opts.encoding);
+
+    stream.on('readable', () => {
+      let chunk: string | null;
 
-  if (isURL(name)) {
+      // tslint:disable-next-line:no-conditional-assignment
+      while ((chunk = stream.read()) !== null) {
+        result += chunk;
+      }
+    });
+
+    return new Promise<string>((resolve, reject) => {
+      stream.on('error', reject);
+      stream.on('end', () => {
+        resolve(result);
+      });
+    });
+  } else if (name in STATIC_ASSETS) {
+    return STATIC_ASSETS[name];
+  } else if (isURL(name)) {
     let response;
     let timeout: NodeJS.Timeout | number | null = null;
     try {
@@ -58,7 +77,7 @@ export async function readFile(name: string, opts: IReadOptions): Promise<string
   }
 }
 
-export async function readParsable(name: string, opts: IReadOptions): Promise<string> {
+export async function readParsable(name: string | number, opts: IReadOptions): Promise<string> {
   try {
     return await readFile(name, opts);
   } catch (ex) {


Test patch:
diff --git a/src/cli/commands/__tests__/lint.test.ts b/src/cli/commands/__tests__/lint.test.ts
index 044d5b11..97e99dc3 100644
--- a/src/cli/commands/__tests__/lint.test.ts
+++ b/src/cli/commands/__tests__/lint.test.ts
@@ -20,6 +20,7 @@ function run(command: string) {
 
 describe('lint', () => {
   let errorSpy: jest.SpyInstance;
+  const { isTTY } = process.stdin;
 
   const results: IRuleResult[] = [
     {
@@ -55,13 +56,30 @@ describe('lint', () => {
 
   afterEach(() => {
     errorSpy.mockRestore();
+    process.stdin.isTTY = isTTY;
   });
 
-  it('shows help when no document argument is passed', async () => {
+  it('shows help when no document and no STDIN are present', async () => {
+    process.stdin.isTTY = true;
     const output = await run('lint');
     expect(output).toContain('documents  Location of JSON/YAML documents');
   });
 
+  describe('when STDIN is present', () => {
+    it('does not show help when documents are missing', async () => {
+      const output = await run('lint');
+      expect(output).not.toContain('documents  Location of JSON/YAML documents');
+    });
+
+    it('calls with lint with STDIN file descriptor', async () => {
+      await run('lint');
+      expect(lint).toBeCalledWith([0], {
+        encoding: 'utf8',
+        format: 'stylish',
+      });
+    });
+  });
+
   it('shows help when invalid arguments are passed', async () => {
     const output = await run('lint --foo');
     expect(output).toContain('documents  Location of JSON/YAML documents. Can be either a file, a glob or');
diff --git a/src/fs/__tests__/__fixtures__/simple b/src/fs/__tests__/__fixtures__/simple
new file mode 100644
index 00000000..4443a906
--- /dev/null
+++ b/src/fs/__tests__/__fixtures__/simple
@@ -0,0 +1,3 @@
+line 1
+line 2
+end
diff --git a/src/fs/__tests__/reader.jest.test.ts b/src/fs/__tests__/reader.jest.test.ts
new file mode 100644
index 00000000..b49bf01a
--- /dev/null
+++ b/src/fs/__tests__/reader.jest.test.ts
@@ -0,0 +1,36 @@
+import { join } from '@stoplight/path';
+import * as fs from 'fs';
+import { STATIC_ASSETS } from '../../assets';
+import { readFile } from '../reader';
+
+describe('readFile util', () => {
+  describe('when a file descriptor is supplied', () => {
+    let fileDescriptor: number;
+
+    beforeEach(() => {
+      fileDescriptor = fs.openSync(join(__dirname, '__fixtures__/simple'), 'r');
+    });
+
+    afterEach(() => {
+      delete STATIC_ASSETS[fileDescriptor];
+    });
+
+    it('reads from file', async () => {
+      const contents = await readFile(fileDescriptor, { encoding: 'utf8' });
+      // normalize line endings
+      expect(contents.replace(/\r\n/g, '\n')).toEqual(`line 1
+line 2
+end
+`);
+    });
+
+    it('always skips static assets', async () => {
+      STATIC_ASSETS[fileDescriptor] = 'test';
+      expect(await readFile(fileDescriptor, { encoding: 'utf8' })).not.toEqual('test');
+    });
+
+    it('throws when fd cannot be accessed', () => {
+      return expect(readFile(2, { encoding: 'utf8' })).rejects.toThrow();
+    });
+  });
+});
diff --git a/test-harness/scenarios/help-no-document.scenario b/test-harness/scenarios/help-no-document.scenario
index 50b5ba69..655cd5df 100644
--- a/test-harness/scenarios/help-no-document.scenario
+++ b/test-harness/scenarios/help-no-document.scenario
@@ -1,14 +1,14 @@
 ====test====
 Errors when no document is provided
 ====command====
-{spectral} lint
-====stderr====
-spectral lint <documents..>
+./scripts/faketty.bash {spectral} lint
+====stdout====
+spectral lint [documents..]
 
 lint JSON/YAML documents from files or URLs
 
 Positionals:
-  documents  Location of JSON/YAML documents. Can be either a file, a glob or fetchable resource(s) on the web.  [array] [required] [default: []]
+  documents  Location of JSON/YAML documents. Can be either a file, a glob or fetchable resource(s) on the web.  [array] [default: []]
 
 Options:
   --version                    Show version number  [boolean]
diff --git a/test-harness/scenarios/stdin-document-with-errors.scenario b/test-harness/scenarios/stdin-document-with-errors.scenario
new file mode 100644
index 00000000..3f387797
--- /dev/null
+++ b/test-harness/scenarios/stdin-document-with-errors.scenario
@@ -0,0 +1,26 @@
+====test====
+Lints stdin input
+====document====
+openapi: 3.0.0
+info:
+  version: 1.0.0
+  title: Stoplight
+paths: {}
+====asset:ruleset====
+extends: spectral:oas
+rules:
+  api-servers: error
+====command====
+cat {document} | {spectral} lint -r {asset:ruleset}
+====status====
+1
+====stdout====
+OpenAPI 3.x detected
+
+<STDIN>
+ 1:1    error  api-servers       OpenAPI `servers` must be present and non-empty array.
+ 1:1  warning  openapi-tags      OpenAPI object should have non-empty `tags` array.
+ 2:6  warning  info-contact      Info object should contain `contact` object.
+ 2:6  warning  info-description  OpenAPI object info `description` must be present and non-empty string.
+
+✖ 4 problems (1 error, 3 warnings, 0 infos, 0 hints)
diff --git a/test-harness/scenarios/stdin.scenario b/test-harness/scenarios/stdin.scenario
new file mode 100644
index 00000000..cdf6552b
--- /dev/null
+++ b/test-harness/scenarios/stdin.scenario
@@ -0,0 +1,20 @@
+====test====
+Lints stdin input
+====document====
+openapi: 3.0.0
+info:
+  version: 1.0.0
+  title: Stoplight
+paths: {}
+====command====
+cat {document} | {spectral} lint
+====stdout====
+OpenAPI 3.x detected
+
+<STDIN>
+ 1:1  warning  api-servers       OpenAPI `servers` must be present and non-empty array.
+ 1:1  warning  openapi-tags      OpenAPI object should have non-empty `tags` array.
+ 2:6  warning  info-contact      Info object should contain `contact` object.
+ 2:6  warning  info-description  OpenAPI object info `description` must be present and non-empty string.
+
+✖ 4 problems (0 errors, 4 warnings, 0 infos, 0 hints)