[lsp] Go to definition

This adds the "textDocument/definition" capability to the language
server. This capability only works for library names and top-level
declarations, i.e. not members or methods.

Test: go test ./...
Change-Id: I6de0ae3730ac54db7975b37f7136514bfa2255d0
Reviewed-on: https://fuchsia-review.googlesource.com/c/fidl-misc/+/395895
Reviewed-by: Pascal Perez <pascallouis@google.com>
Reviewed-by: Benjamin Prosnitz <bprosnitz@google.com>
diff --git a/fidl-lsp/analysis/analyzer.go b/fidl-lsp/analysis/analyzer.go
index e421843..8bbb4c7 100644
--- a/fidl-lsp/analysis/analyzer.go
+++ b/fidl-lsp/analysis/analyzer.go
@@ -8,6 +8,7 @@
 	"fmt"
 	"hash/fnv"
 	"io/ioutil"
+	"log"
 	"os"
 
 	fidlcommon "fidl-lsp/third_party/common"
@@ -15,10 +16,13 @@
 	"fidl-lsp/state"
 )
 
-// Config keeps track of where the Analyzer can find FIDL tools.
+// Config keeps track of where the Analyzer can find FIDL tools, as well as the
+// root build directory, to which all locations in CompiledLibraries are
+// relative.
 // This config will be exposed where possible as settings in FIDL LSP client IDE
 // extensions.
 type Config struct {
+	BuildRootDir   string
 	FidlcPath      string
 	FidlLintPath   string
 	FidlFormatPath string
@@ -29,11 +33,13 @@
 // exists (`json`), its deserialized JSON IR (`ir`), and any diagnostics on it.
 // `deps` and `files` are treated as sets.
 type Library struct {
-	deps  map[fidlcommon.LibraryName]bool
-	files map[string]bool
-	json  string
-	ir    *FidlLibrary // nil if not yet imported
-	diags map[state.FileID][]Diagnostic
+	deps map[fidlcommon.LibraryName]bool
+	// This library's constituent files in the form of absolute paths
+	files   map[string]bool
+	json    string
+	ir      *FidlLibrary // nil if not yet imported
+	diags   map[state.FileID][]Diagnostic
+	symbols symbolMap
 }
 
 // Analyzer compiles FIDL libraries and extracts analyses from them, including
@@ -71,17 +77,18 @@
 		inputFilesFIDL: make(map[state.FileID]string),
 		inputFilesJSON: make(map[fidlcommon.LibraryName]string),
 	}
-	for libName, lib := range compiledLibraries {
-		a.libs[fidlcommon.MustReadLibraryName(libName)] = &Library{
+	for name, lib := range compiledLibraries {
+		libName := fidlcommon.MustReadLibraryName(name)
+		a.libs[libName] = &Library{
 			deps:  make(map[fidlcommon.LibraryName]bool, len(lib.Deps)),
 			files: make(map[string]bool, len(lib.Files)),
 			json:  lib.JSON,
 		}
 		for _, dep := range lib.Deps {
-			a.libs[fidlcommon.MustReadLibraryName(libName)].deps[fidlcommon.MustReadLibraryName(dep)] = true
+			a.libs[libName].deps[fidlcommon.MustReadLibraryName(dep)] = true
 		}
 		for _, file := range lib.Files {
-			a.libs[fidlcommon.MustReadLibraryName(libName)].files[file] = true
+			a.libs[libName].files[file] = true
 		}
 	}
 	return a
@@ -115,12 +122,12 @@
 		lib := a.libs[libName]
 		// If the file currently being edited was already part of lib.files,
 		// delete that and replace it with the temporary/edited version.
-		if absPath, err := state.FileIDToPath(path); err == nil {
+		if absPath, err := state.FileIDToPath(path); err != nil {
+			log.Printf("error parsing: %s\n", err)
+		} else {
 			if _, ok := lib.files[absPath]; ok {
 				delete(lib.files, absPath)
 			}
-		} else {
-			fmt.Fprint(os.Stderr, "error parsing\n")
 		}
 
 		lib.files[inputFilePath] = true
@@ -178,9 +185,9 @@
 }
 
 func (a *Analyzer) inputFileToFileID(inputFilePath string) (state.FileID, error) {
-	for editorFile, inputFile := range a.inputFilesFIDL {
+	for fileID, inputFile := range a.inputFilesFIDL {
 		if inputFile == inputFilePath {
-			return editorFile, nil
+			return fileID, nil
 		}
 	}
 	return "", fmt.Errorf("could not find input file `%s`", inputFilePath)
diff --git a/fidl-lsp/analysis/compile.go b/fidl-lsp/analysis/compile.go
index c71f851..4084400 100644
--- a/fidl-lsp/analysis/compile.go
+++ b/fidl-lsp/analysis/compile.go
@@ -10,34 +10,13 @@
 	"fmt"
 	"io/ioutil"
 	"os/exec"
+	"strings"
 
 	fidlcommon "fidl-lsp/third_party/common"
 
 	"fidl-lsp/state"
 )
 
-// Read in the JSON IR at absolute filepath `jsonPath`.
-func (a *Analyzer) importLibrary(jsonPath string) error {
-	data, err := ioutil.ReadFile(jsonPath)
-	if err != nil {
-		return err
-	}
-	var lib FidlLibrary
-	if err := json.Unmarshal(data, &lib); err != nil {
-		return err
-	}
-	// TODO: import more things? files + deps?
-	if _, ok := a.libs[fidlcommon.MustReadLibraryName(lib.Name)]; !ok {
-		a.libs[fidlcommon.MustReadLibraryName(lib.Name)] = &Library{
-			json: jsonPath,
-		}
-	}
-	a.libs[fidlcommon.MustReadLibraryName(lib.Name)].ir = &lib
-
-	// TODO: construct symbol map from JSON IR for the file's library
-	return nil
-}
-
 type compileResult struct {
 	lib   fidlcommon.LibraryName
 	diags map[state.FileID][]Diagnostic
@@ -75,29 +54,36 @@
 		if err := a.importLibrary(jsonPath); err != nil {
 			return compileResult{}, fmt.Errorf("error on adding compiled JSON: %s", err)
 		}
+	} else {
+		// TODO: there is no platform-independent way in Go to check an error
+		// status code, but if we can, we should somehow determine if fidlc
+		// exited with status code 1 (failure due to FIDL errors) or something
+		// else (internal error or command otherwise failed).
+		// If it's status code 1, we should continue, but anything else, we
+		// should return an error from `compile` to signal failure.
 	}
 
 	diags := make(map[state.FileID][]Diagnostic)
 	for fileName := range a.libs[libraryName].files {
-		editorFile, err := a.inputFileToFileID(fileName)
+		fileID, err := a.inputFileToFileID(fileName)
 		if err == nil {
-			diags[editorFile] = []Diagnostic{}
+			diags[fileID] = []Diagnostic{}
 		}
 	}
 	if errorsAndWarnings, err := a.fidlcDiagsFromStderr(stderr.Bytes()); err == nil {
-		for editorFile, fileDiags := range errorsAndWarnings {
-			if _, ok := diags[editorFile]; !ok {
-				diags[editorFile] = []Diagnostic{}
+		for fileID, fileDiags := range errorsAndWarnings {
+			if _, ok := diags[fileID]; !ok {
+				diags[fileID] = []Diagnostic{}
 			}
-			diags[editorFile] = append(diags[editorFile], fileDiags...)
+			diags[fileID] = append(diags[fileID], fileDiags...)
 		}
 	}
 	if lints, err := a.runFidlLint(a.inputFilesFIDL[path]); err == nil {
-		for editorFile, fileDiags := range lints {
-			if _, ok := diags[editorFile]; !ok {
-				diags[editorFile] = []Diagnostic{}
+		for fileID, fileDiags := range lints {
+			if _, ok := diags[fileID]; !ok {
+				diags[fileID] = []Diagnostic{}
 			}
-			diags[editorFile] = append(diags[editorFile], fileDiags...)
+			diags[fileID] = append(diags[fileID], fileDiags...)
 		}
 	}
 
@@ -106,3 +92,219 @@
 		diags: diags,
 	}, nil
 }
+
+// Read in the JSON IR at absolute filepath `jsonPath`.
+func (a *Analyzer) importLibrary(jsonPath string) error {
+	data, err := ioutil.ReadFile(jsonPath)
+	if err != nil {
+		return err
+	}
+	var lib FidlLibrary
+	if err := json.Unmarshal(data, &lib); err != nil {
+		return err
+	}
+	// TODO: import more things? files + deps?
+	libName := fidlcommon.MustReadLibraryName(lib.Name)
+	if _, ok := a.libs[libName]; !ok {
+		a.libs[libName] = &Library{}
+	}
+	a.libs[libName].ir = &lib
+	a.libs[libName].json = jsonPath
+
+	// Construct symbol map from JSON IR for the file's library
+	symbols, err := a.genSymbolMap(lib)
+	if err != nil {
+		return fmt.Errorf("unable to generate symbol map from library %s: %s", lib.Name, err)
+	}
+	a.libs[libName].symbols = symbols
+
+	return nil
+}
+
+type symbolKind string
+
+const (
+	bitsKind      symbolKind = "bits"
+	constKind                = "const"
+	enumKind                 = "enum"
+	protocolKind             = "protocol"
+	serviceKind              = "service"
+	structKind               = "struct"
+	tableKind                = "table"
+	unionKind                = "union"
+	typeAliasKind            = "typeAlias"
+)
+
+type symbolInfo struct {
+	lib                string
+	name               string
+	definition         state.Location
+	attrs              []attribute
+	kind               symbolKind
+	isMember           bool
+	isMethod           bool
+	typeInfo           interface{}
+	maybeFromTypeAlias typeCtor
+}
+
+type symbolMap map[string]*symbolInfo
+
+func (a *Analyzer) genSymbolMap(l FidlLibrary) (symbolMap, error) {
+	// TODO: skip SomeLongAnonymousPrefix* structs? (are we double counting method request/response params?)
+	// TODO: how to handle const literals?
+
+	sm := make(symbolMap)
+
+	for _, d := range l.BitsDecls {
+		sm[d.Name] = &symbolInfo{
+			lib:                l.Name,
+			name:               d.Name,
+			definition:         a.fidlLocToStateLoc(d.Loc),
+			kind:               bitsKind,
+			typeInfo:           d.Type,
+			maybeFromTypeAlias: d.FromTypeAlias,
+			attrs:              d.Attrs,
+		}
+		a.addMembersToSymbolMap(sm, l.Name, d.Name, bitsKind, d.Members)
+	}
+	for _, d := range l.ConstDecls {
+		sm[d.Name] = &symbolInfo{
+			lib:                l.Name,
+			name:               d.Name,
+			definition:         a.fidlLocToStateLoc(d.Loc),
+			kind:               constKind,
+			typeInfo:           d.Type,
+			maybeFromTypeAlias: d.FromTypeAlias,
+			attrs:              d.Attrs,
+		}
+	}
+	for _, d := range l.EnumDecls {
+		sm[d.Name] = &symbolInfo{
+			lib:                l.Name,
+			name:               d.Name,
+			definition:         a.fidlLocToStateLoc(d.Loc),
+			kind:               enumKind,
+			typeInfo:           d.Type,
+			maybeFromTypeAlias: d.FromTypeAlias,
+			attrs:              d.Attrs,
+		}
+		a.addMembersToSymbolMap(sm, l.Name, d.Name, enumKind, d.Members)
+	}
+	for _, d := range l.ProtocolDecls {
+		sm[d.Name] = &symbolInfo{
+			lib:        l.Name,
+			name:       d.Name,
+			definition: a.fidlLocToStateLoc(d.Loc),
+			kind:       protocolKind,
+			attrs:      d.Attrs,
+		}
+		for _, m := range d.Methods {
+			methodName := fmt.Sprintf("%s.%s", d.Name, m.Name)
+			sm[methodName] = &symbolInfo{
+				lib:        l.Name,
+				name:       methodName,
+				definition: a.fidlLocToStateLoc(m.Loc),
+				kind:       protocolKind,
+				isMethod:   true,
+				attrs:      m.Attrs,
+			}
+			if len(m.MaybeRequest) > 0 {
+				a.addMembersToSymbolMap(sm, l.Name, methodName, protocolKind, m.MaybeRequest)
+			}
+			if len(m.MaybeResponse) > 0 {
+				a.addMembersToSymbolMap(sm, l.Name, methodName, protocolKind, m.MaybeResponse)
+			}
+		}
+	}
+	for _, d := range l.ServiceDecls {
+		sm[d.Name] = &symbolInfo{
+			lib:        l.Name,
+			name:       d.Name,
+			definition: a.fidlLocToStateLoc(d.Loc),
+			kind:       serviceKind,
+			attrs:      d.Attrs,
+		}
+		a.addMembersToSymbolMap(sm, l.Name, d.Name, serviceKind, d.Members)
+	}
+	for _, d := range l.StructDecls {
+		sm[d.Name] = &symbolInfo{
+			lib:        l.Name,
+			name:       d.Name,
+			definition: a.fidlLocToStateLoc(d.Loc),
+			kind:       structKind,
+			attrs:      d.Attrs,
+		}
+		a.addMembersToSymbolMap(sm, l.Name, d.Name, structKind, d.Members)
+	}
+	for _, d := range l.TableDecls {
+		sm[d.Name] = &symbolInfo{
+			lib:        l.Name,
+			name:       d.Name,
+			definition: a.fidlLocToStateLoc(d.Loc),
+			kind:       tableKind,
+			attrs:      d.Attrs,
+		}
+		a.addMembersToSymbolMap(sm, l.Name, d.Name, tableKind, d.Members)
+	}
+	for _, d := range l.UnionDecls {
+		sm[d.Name] = &symbolInfo{
+			lib:        l.Name,
+			name:       d.Name,
+			definition: a.fidlLocToStateLoc(d.Loc),
+			kind:       unionKind,
+			attrs:      d.Attrs,
+		}
+		a.addMembersToSymbolMap(sm, l.Name, d.Name, unionKind, d.Members)
+	}
+	for _, d := range l.TypeAliasDecls {
+		sm[d.Name] = &symbolInfo{
+			lib:        l.Name,
+			name:       d.Name,
+			definition: a.fidlLocToStateLoc(d.Loc),
+			kind:       typeAliasKind,
+			typeInfo:   d.TypeCtor,
+			attrs:      d.Attrs,
+		}
+	}
+
+	return sm, nil
+}
+
+func (a *Analyzer) addMembersToSymbolMap(sm symbolMap, libName string, declName string, kind symbolKind, members []member) {
+	for _, m := range members {
+		memberName := fmt.Sprintf("%s.%s", declName, m.Name)
+		sm[memberName] = &symbolInfo{
+			lib:                libName,
+			name:               memberName,
+			definition:         a.fidlLocToStateLoc(m.Loc),
+			kind:               kind,
+			isMember:           true,
+			typeInfo:           m.Type,
+			maybeFromTypeAlias: m.FromTypeAlias,
+			attrs:              m.Attrs,
+		}
+	}
+}
+
+func (a *Analyzer) fidlLocToStateLoc(loc location) state.Location {
+	// If this JSON IR was compiled by the language server, the `Filename`s for
+	// for all the symbols will be tmp input files from the Analyzer. We want
+	// to point at the corresponding editor files for these tmp files.
+	fileID, err := a.inputFileToFileID(loc.Filename)
+	if err != nil {
+		// We assume `Filename` is a filepath to a precompiled JSON IR, provided
+		// in the CompiledLibraries on startup.
+		//
+		// The `Filename` in a decl's location is recorded relative to where
+		// fidlc was invoked. All the locations in CompiledLibraries are
+		// prepended with "../../" which can be replaced by $FUCHSIA_DIR/.
+		fileID = state.FileID(strings.Replace(loc.Filename, "../..", a.cfg.BuildRootDir, 1))
+	}
+	return state.Location{
+		FileID: fileID,
+		Range: state.Range{
+			Start: state.Position{Line: loc.Line - 1, Character: loc.Column - 1},
+			End:   state.Position{Line: loc.Line - 1, Character: loc.Column - 1 + loc.Length},
+		},
+	}
+}
diff --git a/fidl-lsp/analysis/definition.go b/fidl-lsp/analysis/definition.go
new file mode 100644
index 0000000..3063f59
--- /dev/null
+++ b/fidl-lsp/analysis/definition.go
@@ -0,0 +1,154 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package analysis
+
+import (
+	"fmt"
+	"io/ioutil"
+	"strings"
+
+	fidlcommon "fidl-lsp/third_party/common"
+
+	"fidl-lsp/state"
+)
+
+// DefinitionOfSymbol returns the location (or locations) where the given symbol
+// is defined, as long as that location is available to the Analyzer (i.e. in
+// a.libs somewhere).
+//
+// For a library name, e.g. `fuchsia.foo`, DefinitionOfSymbol returns the
+// locations of the `library` declarations for each file in that library.
+//
+// DefinitionOfSymbol only works for library names and top-level names: enums,
+// bits, structs, protocols, etc. -- not for member names. For example, it can
+// find the definition of `Foo` in the following example:
+//
+// 		protocol P {
+// 			Method(Foo foo);
+// 		};
+//
+// But it can't find the definition of `B.FOO`:
+//
+// 		bits B {
+//			FOO = 0;
+// 		}
+//		const ZERO = B.FOO;
+func (a *Analyzer) DefinitionOfSymbol(fs *state.FileSystem, sym state.Symbol) ([]state.Location, error) {
+	// If `sym` is a library name, return locations pointing at all the files in
+	// the library (specifically, pointing at their `library` declarations).
+	libName, err := fidlcommon.ReadLibraryName(sym.Name)
+	if err == nil {
+		if _, isLib := a.libs[libName]; isLib {
+			return a.declarationsOfLibrary(fs, libName), nil
+		}
+	}
+
+	// Otherwise, we assume it is a local or fully-qualified name
+	name, err := a.symbolToFullyQualifiedName(fs, sym)
+	if err != nil {
+		return nil, fmt.Errorf(
+			"could not convert symbol `%s` to fully-qualified name: %s",
+			sym.Name,
+			err,
+		)
+	}
+
+	lib, ok := a.libs[name.LibraryName()]
+	if !ok {
+		return nil, fmt.Errorf("unknown library `%s`", name.LibraryName().FullyQualifiedName())
+	}
+	if lib.ir == nil {
+		if err := a.importLibrary(lib.json); err != nil {
+			return nil, fmt.Errorf(
+				"error importing library `%s`: %s",
+				name.LibraryName().FullyQualifiedName(),
+				err,
+			)
+		}
+	}
+
+	// Check that we have a symbolMap for this library, and look up this
+	// symbol's definition location in that symbolMap.
+	if lib.symbols == nil {
+		return nil, fmt.Errorf(
+			"no symbol map for library `%s`",
+			name.LibraryName().FullyQualifiedName(),
+		)
+	}
+	symInfo, ok := lib.symbols[name.FullyQualifiedName()]
+	if !ok {
+		return nil, fmt.Errorf("could not find definition of symbol `%s`", name.FullyQualifiedName())
+	}
+
+	return []state.Location{symInfo.definition}, nil
+}
+
+func (a *Analyzer) declarationsOfLibrary(fs *state.FileSystem, lib fidlcommon.LibraryName) []state.Location {
+	files := []state.FileID{}
+	for file := range a.libs[lib].files {
+		// These files are all absolute paths, but some are to temporary input
+		// files used by the Analyzer. For these, we want to convert the path
+		// to the document URI the editor knows about.
+		if fileID, err := a.inputFileToFileID(file); err != nil {
+			files = append(files, state.FileID(file))
+		} else {
+			files = append(files, fileID)
+		}
+	}
+
+	locs := []state.Location{}
+	// Find the `library` declaration in each file
+	for _, fileID := range files {
+		file, err := fs.File(fileID)
+		if err != nil {
+			// If we don't have the file in memory, read it in
+			bytes, err := ioutil.ReadFile(string(fileID))
+			if err != nil {
+				continue
+			}
+			file = string(bytes)
+		}
+
+		if libraryMatch, ok := state.ParseLibraryMatch(file); ok {
+			locs = append(locs, state.Location{
+				FileID: fileID,
+				Range:  libraryMatch.Range,
+			})
+		}
+	}
+	return locs
+}
+
+func (a *Analyzer) symbolToFullyQualifiedName(fs *state.FileSystem, sym state.Symbol) (fidlcommon.Name, error) {
+	// If `sym` is a local name (not fully-qualified), we create a FQN by
+	// attaching its library name.
+	var fqn string
+	if !strings.Contains(sym.Name, ".") {
+		file, err := fs.File(sym.Location.FileID)
+		if err != nil {
+			return fidlcommon.Name{}, fmt.Errorf("could not open file `%s`", sym.Location.FileID)
+		}
+		libName, err := state.LibraryOfFile(file)
+		if err != nil {
+			return fidlcommon.Name{}, fmt.Errorf(
+				"could not find library of symbol `%s` in file `%s`",
+				sym.Name,
+				sym.Location.FileID,
+			)
+		}
+		fqn = libName.FullyQualifiedName() + "/" + sym.Name
+	} else {
+		// If the symbol contains '.', we assume it is a fully-qualified name.
+		i := strings.LastIndex(sym.Name, ".")
+		fqn = sym.Name[:i] + "/" + sym.Name[i+1:]
+	}
+
+	// Convert `fqn` to a fidlcommon.Name.
+	name, err := fidlcommon.ReadName(fqn)
+	if err != nil {
+		return fidlcommon.Name{}, fmt.Errorf("could not read fully-qualified name `%s`: %s", fqn, err)
+	}
+	return name, nil
+}
diff --git a/fidl-lsp/analysis/definition_test.go b/fidl-lsp/analysis/definition_test.go
new file mode 100644
index 0000000..be321e6
--- /dev/null
+++ b/fidl-lsp/analysis/definition_test.go
@@ -0,0 +1,326 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package analysis_test
+
+import (
+	"os"
+	"testing"
+
+	"fidl-lsp/analysis"
+	"fidl-lsp/state"
+)
+
+var (
+	fuchsiaDir     = os.Getenv("FUCHSIA_DIR")
+	toolsPath      = fuchsiaDir + "/out/default.zircon/tools/"
+	fidlcPath      = toolsPath + "fidlc"
+	fidlLintPath   = toolsPath + "fidl-lint"
+	fidlFormatPath = toolsPath + "fidl-format"
+)
+
+func defaultConfig() analysis.Config {
+	return analysis.Config{
+		BuildRootDir:   fuchsiaDir,
+		FidlcPath:      fidlcPath,
+		FidlLintPath:   fidlLintPath,
+		FidlFormatPath: fidlFormatPath,
+	}
+}
+
+func TestDefinitionOfLibrary(t *testing.T) {
+	analyzer := analysis.NewAnalyzer(defaultConfig(), analysis.CompiledLibraries{})
+	fs := state.NewFileSystem()
+	fs.NewFile(
+		"file.fidl",
+		`library test;`,
+	)
+	if err := analyzer.Analyze(fs, "file.fidl"); err != nil {
+		t.Fatalf("failed to analyze file: %s", err)
+	}
+
+	locs, err := analyzer.DefinitionOfSymbol(fs, state.Symbol{
+		Name: "test",
+		Location: state.Location{
+			FileID: state.FileID("file.fidl"),
+			Range: state.Range{
+				Start: state.Position{Line: 0, Character: 8},
+				End:   state.Position{Line: 0, Character: 12},
+			},
+		},
+	})
+	if err != nil {
+		t.Fatalf("error getting definition of symbol: %s", err)
+	}
+
+	expLoc := state.Location{
+		FileID: state.FileID("file.fidl"),
+		Range: state.Range{
+			Start: state.Position{Line: 0, Character: 8},
+			End:   state.Position{Line: 0, Character: 12},
+		},
+	}
+	if len(locs) != 1 {
+		t.Fatalf("expected 1 definition location")
+	}
+	if locs[0] != expLoc {
+		t.Errorf("unexpected definition of symbol: expected %v, got %v", expLoc, locs[0])
+	}
+}
+
+func TestDefinitionOfLibraryMultipleFiles(t *testing.T) {
+	analyzer := analysis.NewAnalyzer(defaultConfig(), analysis.CompiledLibraries{})
+	fs := state.NewFileSystem()
+	fs.NewFile(
+		"file1.fidl",
+		`library test;`,
+	)
+	fs.NewFile(
+		"file2.fidl",
+		`library test;`,
+	)
+	if err := analyzer.Analyze(fs, "file1.fidl"); err != nil {
+		t.Fatalf("failed to analyze file: %s", err)
+	}
+	if err := analyzer.Analyze(fs, "file2.fidl"); err != nil {
+		t.Fatalf("failed to analyze file: %s", err)
+	}
+
+	locs, err := analyzer.DefinitionOfSymbol(fs, state.Symbol{
+		Name: "test",
+		Location: state.Location{
+			FileID: state.FileID("file1.fidl"),
+			Range: state.Range{
+				Start: state.Position{Line: 0, Character: 8},
+				End:   state.Position{Line: 0, Character: 12},
+			},
+		},
+	})
+	if err != nil {
+		t.Fatalf("error getting definition of symbol: %s", err)
+	}
+
+	expLocs := []state.Location{
+		{
+			FileID: state.FileID("file1.fidl"),
+			Range: state.Range{
+				Start: state.Position{Line: 0, Character: 8},
+				End:   state.Position{Line: 0, Character: 12},
+			},
+		},
+		{
+			FileID: state.FileID("file2.fidl"),
+			Range: state.Range{
+				Start: state.Position{Line: 0, Character: 8},
+				End:   state.Position{Line: 0, Character: 12},
+			},
+		},
+	}
+	if len(locs) != len(expLocs) {
+		t.Fatalf("expected %d definition locations", len(expLocs))
+	}
+	for _, expLoc := range expLocs {
+		found := false
+		for _, loc := range locs {
+			if expLoc == loc {
+				found = true
+				break
+			}
+		}
+		if !found {
+			t.Errorf("expected but did not find definition of symbol: %v", expLoc)
+		}
+	}
+}
+
+func TestDefinitionOfLibraryImport(t *testing.T) {
+	analyzer := analysis.NewAnalyzer(defaultConfig(), analysis.CompiledLibraries{})
+	fs := state.NewFileSystem()
+	fs.NewFile(
+		"import.fidl",
+		`
+library fuchsia.import;
+
+const uint8 FOO = 0;
+`,
+	)
+	fs.NewFile(
+		"test.fidl",
+		`
+library test;
+using fuchsia.import;
+
+const uint8 BAR = fuchsia.import.FOO;
+`,
+	)
+	if err := analyzer.Analyze(fs, "import.fidl"); err != nil {
+		t.Fatalf("failed to analyze file: %s", err)
+	}
+	if err := analyzer.Analyze(fs, "test.fidl"); err != nil {
+		t.Fatalf("failed to analyze file: %s", err)
+	}
+
+	locs, err := analyzer.DefinitionOfSymbol(fs, state.Symbol{
+		Name: "fuchsia.import",
+		Location: state.Location{
+			FileID: state.FileID("test.fidl"),
+			Range: state.Range{
+				Start: state.Position{Line: 3, Character: 6},
+				End:   state.Position{Line: 3, Character: 20},
+			},
+		},
+	})
+	if err != nil {
+		t.Fatalf("error getting definition of symbol: %s", err)
+	}
+
+	expLoc := state.Location{
+		FileID: state.FileID("import.fidl"),
+		Range: state.Range{
+			Start: state.Position{Line: 1, Character: 8},
+			End:   state.Position{Line: 1, Character: 22},
+		},
+	}
+	if len(locs) != 1 {
+		t.Fatalf("expected 1 definition location")
+	}
+	if locs[0] != expLoc {
+		t.Errorf("unexpected definition of symbol: expected %v, got %v", expLoc, locs[0])
+	}
+}
+
+func TestDefinitionOfFullyQualifiedSymbol(t *testing.T) {
+	analyzer := analysis.NewAnalyzer(defaultConfig(), analysis.CompiledLibraries{})
+	fs := state.NewFileSystem()
+	fs.NewFile(
+		"import.fidl",
+		`
+library fuchsia.import;
+
+struct Foo {};
+`,
+	)
+	fs.NewFile(
+		"test.fidl",
+		`
+library test;
+using fuchsia.import;
+
+protocol Baz {
+	Method(fuchsia.import.Foo f);
+};
+`,
+	)
+	if err := analyzer.Analyze(fs, "import.fidl"); err != nil {
+		t.Fatalf("failed to analyze file: %s", err)
+	}
+	if err := analyzer.Analyze(fs, "test.fidl"); err != nil {
+		t.Fatalf("failed to analyze file: %s", err)
+	}
+
+	locs, err := analyzer.DefinitionOfSymbol(fs, state.Symbol{
+		Name: "fuchsia.import.Foo",
+		Location: state.Location{
+			FileID: state.FileID("test.fidl"),
+			Range: state.Range{
+				Start: state.Position{Line: 5, Character: 11},
+				End:   state.Position{Line: 5, Character: 29},
+			},
+		},
+	})
+	if err != nil {
+		t.Fatalf("error getting definition of symbol: %s", err)
+	}
+
+	expLoc := state.Location{
+		FileID: state.FileID("import.fidl"),
+		Range: state.Range{
+			Start: state.Position{Line: 3, Character: 7},
+			End:   state.Position{Line: 3, Character: 10},
+		},
+	}
+	if len(locs) != 1 {
+		t.Fatalf("expected 1 definition location")
+	}
+	if locs[0] != expLoc {
+		t.Errorf("unexpected definition of symbol: expected %v, got %v", expLoc, locs[0])
+	}
+}
+
+func TestDefinitionOfLocalSymbol(t *testing.T) {
+	analyzer := analysis.NewAnalyzer(defaultConfig(), analysis.CompiledLibraries{})
+	fs := state.NewFileSystem()
+	fs.NewFile(
+		"test.fidl",
+		`
+library test;
+
+struct Foo {};
+
+protocol Baz {
+	Method(Foo f);
+};
+`,
+	)
+	if err := analyzer.Analyze(fs, "test.fidl"); err != nil {
+		t.Fatalf("failed to analyze file: %s", err)
+	}
+
+	locs, err := analyzer.DefinitionOfSymbol(fs, state.Symbol{
+		Name: "Foo",
+		Location: state.Location{
+			FileID: state.FileID("test.fidl"),
+			Range: state.Range{
+				Start: state.Position{Line: 6, Character: 11},
+				End:   state.Position{Line: 6, Character: 14},
+			},
+		},
+	})
+	if err != nil {
+		t.Fatalf("error getting definition of symbol: %s", err)
+	}
+
+	expLoc := state.Location{
+		FileID: state.FileID("test.fidl"),
+		Range: state.Range{
+			Start: state.Position{Line: 3, Character: 7},
+			End:   state.Position{Line: 3, Character: 10},
+		},
+	}
+	if len(locs) != 1 {
+		t.Fatalf("expected 1 definition location")
+	}
+	if locs[0] != expLoc {
+		t.Errorf("unexpected definition of symbol: expected %v, got %v", expLoc, locs[0])
+	}
+}
+
+func TestDefinitionOfReservedKeyword(t *testing.T) {
+	analyzer := analysis.NewAnalyzer(defaultConfig(), analysis.CompiledLibraries{})
+	fs := state.NewFileSystem()
+	fs.NewFile(
+		"test.fidl",
+		`
+library test;
+
+struct Foo {};
+`,
+	)
+	if err := analyzer.Analyze(fs, "test.fidl"); err != nil {
+		t.Fatalf("failed to analyze file: %s", err)
+	}
+
+	if _, err := analyzer.DefinitionOfSymbol(fs, state.Symbol{
+		Name: "struct",
+		Location: state.Location{
+			FileID: state.FileID("test.fidl"),
+			Range: state.Range{
+				Start: state.Position{Line: 3, Character: 0},
+				End:   state.Position{Line: 3, Character: 6},
+			},
+		},
+	}); err == nil {
+		t.Fatalf("expect error on definition of reserved keyword")
+	}
+}
diff --git a/fidl-lsp/analysis/deps.go b/fidl-lsp/analysis/deps.go
index dda1100..506ecfa 100644
--- a/fidl-lsp/analysis/deps.go
+++ b/fidl-lsp/analysis/deps.go
@@ -30,8 +30,8 @@
 	JSON  string
 }
 
-// GetFidlProject builds a CompiledLibraries out of a fidl_project.json file.
-func GetFidlProject(fidlProjectPath string) (CompiledLibraries, error) {
+// LoadFidlProject builds a CompiledLibraries out of a fidl_project.json file.
+func LoadFidlProject(fidlProjectPath string) (CompiledLibraries, error) {
 	data, err := ioutil.ReadFile(fidlProjectPath)
 	if err != nil {
 		return nil, fmt.Errorf("failed to read file: %s", err)
diff --git a/fidl-lsp/analysis/deps_test.go b/fidl-lsp/analysis/deps_test.go
index 942946f..04e7140 100644
--- a/fidl-lsp/analysis/deps_test.go
+++ b/fidl-lsp/analysis/deps_test.go
@@ -75,7 +75,7 @@
 	// itself, which we don't check. So there should be (# of dependencies + 1)
 	// total --files args.
 	if len(fidlcArgs) != len(expArgs)+1 {
-		t.Fatalf("incorrect number of fidlc --files args %d: expected %d", len(fidlcArgs), len(expArgs)+len(fs.Files()))
+		t.Fatalf("incorrect number of fidlc --files args %d: expected %d", len(fidlcArgs), len(expArgs)+fs.NFiles())
 	}
 	for _, expArg := range expArgs {
 		if indexOf(fidlcArgs, expArg) == -1 {
@@ -128,7 +128,7 @@
 		"--files",
 	}
 	if len(fidlcArgs) != len(expArgs)+1 {
-		t.Fatalf("incorrect number of fidlc --files args %d: expected %d", len(fidlcArgs), len(expArgs)+len(fs.Files()))
+		t.Fatalf("incorrect number of fidlc --files args %d: expected %d", len(fidlcArgs), len(expArgs)+fs.NFiles())
 	}
 	for _, expArg := range expArgs {
 		if indexOf(fidlcArgs, expArg) == -1 {
@@ -194,7 +194,7 @@
 	}
 	if len(fidlcArgs) != len(expArgs)+2 {
 		t.Errorf("args: %v\n", fidlcArgs)
-		t.Fatalf("incorrect number of fidlc --files args %d: expected %d", len(fidlcArgs), len(expArgs)+len(fs.Files()))
+		t.Fatalf("incorrect number of fidlc --files args %d: expected %d", len(fidlcArgs), len(expArgs)+fs.NFiles())
 	}
 	// We don't test the topological sort, just that all the required files are present.
 	for _, expArg := range expArgs {
diff --git a/fidl-lsp/analysis/diagnostics.go b/fidl-lsp/analysis/diagnostics.go
index eb93394..05a42b6 100644
--- a/fidl-lsp/analysis/diagnostics.go
+++ b/fidl-lsp/analysis/diagnostics.go
@@ -55,15 +55,15 @@
 	}
 	fileToDiags := make(map[state.FileID][]Diagnostic)
 	for i := range diags {
-		editorFile, err := a.inputFileToFileID(diags[i].Path)
+		fileID, err := a.inputFileToFileID(diags[i].Path)
 		if err != nil {
 			continue
 		}
-		if _, ok := fileToDiags[editorFile]; !ok {
-			fileToDiags[editorFile] = []Diagnostic{}
+		if _, ok := fileToDiags[fileID]; !ok {
+			fileToDiags[fileID] = []Diagnostic{}
 		}
 		diags[i].Source = Fidlc
-		fileToDiags[editorFile] = append(fileToDiags[editorFile], diags[i])
+		fileToDiags[fileID] = append(fileToDiags[fileID], diags[i])
 	}
 	return fileToDiags, nil
 }
@@ -81,15 +81,15 @@
 	}
 	fileToDiags := make(map[state.FileID][]Diagnostic)
 	for i := range diags {
-		editorFile, err := a.inputFileToFileID(diags[i].Path)
+		fileID, err := a.inputFileToFileID(diags[i].Path)
 		if err != nil {
 			continue
 		}
-		if _, ok := fileToDiags[editorFile]; !ok {
-			fileToDiags[editorFile] = []Diagnostic{}
+		if _, ok := fileToDiags[fileID]; !ok {
+			fileToDiags[fileID] = []Diagnostic{}
 		}
 		diags[i].Source = FidlLint
-		fileToDiags[editorFile] = append(fileToDiags[editorFile], diags[i])
+		fileToDiags[fileID] = append(fileToDiags[fileID], diags[i])
 	}
 	return fileToDiags, nil
 }
diff --git a/fidl-lsp/langserver/definition.go b/fidl-lsp/langserver/definition.go
new file mode 100644
index 0000000..d0468ea
--- /dev/null
+++ b/fidl-lsp/langserver/definition.go
@@ -0,0 +1,45 @@
+// Copyright 2020 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+package langserver
+
+import (
+	"github.com/sourcegraph/go-lsp"
+
+	"fidl-lsp/state"
+)
+
+func (h *LangHandler) handleDefinition(params lsp.TextDocumentPositionParams) ([]lsp.Location, error) {
+	sym, err := h.fs.SymbolAtPos(
+		state.FileID(params.TextDocument.URI),
+		state.Position{Line: params.Position.Line, Character: params.Position.Character},
+	)
+	if err != nil {
+		h.log.Printf(
+			"could not find symbol at position `%#v` in document `%s`\n",
+			params.Position,
+			params.TextDocument.URI,
+		)
+		return nil, err
+	}
+
+	locs, err := h.analyzer.DefinitionOfSymbol(h.fs, sym)
+	if err != nil {
+		h.log.Printf("error on definition: %s\n", err)
+		return nil, err
+	}
+
+	// Convert state.Locations --> lsp.Locations
+	lspLocs := make([]lsp.Location, len(locs))
+	for i, loc := range locs {
+		lspLocs[i] = lsp.Location{
+			URI: lsp.DocumentURI(loc.FileID),
+			Range: lsp.Range{
+				Start: lsp.Position{Line: loc.Range.Start.Line, Character: loc.Range.Start.Character},
+				End:   lsp.Position{Line: loc.Range.End.Line, Character: loc.Range.End.Character},
+			},
+		}
+	}
+	return lspLocs, nil
+}
diff --git a/fidl-lsp/langserver/diagnostics.go b/fidl-lsp/langserver/diagnostics.go
index 8845107..01d1212 100644
--- a/fidl-lsp/langserver/diagnostics.go
+++ b/fidl-lsp/langserver/diagnostics.go
@@ -49,8 +49,8 @@
 
 	// Convert analysis.Diagnostics --> lsp.Diagnostics
 	lspDiags := make(map[lsp.DocumentURI][]lsp.Diagnostic)
-	for editorFile, fileDiags := range diags {
-		fileURI := lsp.DocumentURI(editorFile)
+	for fileID, fileDiags := range diags {
+		fileURI := lsp.DocumentURI(fileID)
 		lspDiags[fileURI] = []lsp.Diagnostic{}
 
 		for _, diag := range fileDiags {
diff --git a/fidl-lsp/langserver/handler.go b/fidl-lsp/langserver/handler.go
index 8ba6a13..f688369 100644
--- a/fidl-lsp/langserver/handler.go
+++ b/fidl-lsp/langserver/handler.go
@@ -52,7 +52,10 @@
 
 type Config struct {
 	serverCapabilities serverCapabilities
-	baseRefURI         string
+
+	// The base URI for reference links. Currently we linkify Fuchsia platform
+	// library imports, and the links point to the SDK docs.
+	baseRefURI string
 }
 
 func (c *Config) FormatRefURI(libraryName fidlcommon.LibraryName) string {
@@ -63,6 +66,7 @@
 func NewDefaultConfig() Config {
 	return Config{
 		serverCapabilities: serverCapabilities{
+			DefinitionProvider:         true,
 			DocumentFormattingProvider: true,
 			DocumentLinkProvider:       &documentLinkOptions{},
 			TextDocumentSync: &lsp.TextDocumentSyncOptionsOrKind{
@@ -223,6 +227,17 @@
 		h.publishDiagnostics(ctx, conn, params.TextDocument.URI)
 		return nil, nil
 
+	case "textDocument/definition":
+		if req.Params == nil {
+			return nil, &jsonrpc2.Error{Code: jsonrpc2.CodeInvalidParams}
+		}
+		var params lsp.TextDocumentPositionParams
+		if err := json.Unmarshal(*req.Params, &params); err != nil {
+			return nil, err
+		}
+
+		return h.handleDefinition(params)
+
 	case "textDocument/documentLink":
 		if req.Params == nil {
 			return nil, &jsonrpc2.Error{Code: jsonrpc2.CodeInvalidParams}
diff --git a/fidl-lsp/langserver/links.go b/fidl-lsp/langserver/links.go
index f189383..378bff8 100644
--- a/fidl-lsp/langserver/links.go
+++ b/fidl-lsp/langserver/links.go
@@ -15,10 +15,10 @@
 func (h *LangHandler) handleDocumentLinks(params documentLinkParams) ([]documentLink, error) {
 	file, err := h.fs.File(state.FileID(params.TextDocument.URI))
 	if err != nil {
-		return []documentLink{}, fmt.Errorf("could not find file `%s`", params.TextDocument.URI)
+		return nil, fmt.Errorf("could not find file `%s`", params.TextDocument.URI)
 	}
 	imports := state.ParsePlatformImportsMatch(file)
-	links := make([]documentLink, 0)
+	links := []documentLink{}
 	for _, m := range imports {
 		links = append(links, documentLink{
 			Range: lsp.Range{
diff --git a/fidl-lsp/langserver/links_test.go b/fidl-lsp/langserver/links_test.go
index 65eccf6..2c65e7a 100644
--- a/fidl-lsp/langserver/links_test.go
+++ b/fidl-lsp/langserver/links_test.go
@@ -5,6 +5,7 @@
 package langserver
 
 import (
+	"strings"
 	"testing"
 
 	"github.com/sourcegraph/go-lsp"
@@ -19,6 +20,7 @@
 using fuchsia.io;
 using non.platform.import;
 `
+	lines := strings.SplitAfter(fileText, "\n")
 
 	handler := NewHandlerWithFiles(TestFile{
 		Name: "test.fidl",
@@ -43,14 +45,14 @@
 		found := false
 		for _, link := range links {
 			start, err := state.OffsetInFile(
-				fileText,
+				lines,
 				state.Position{Line: link.Range.Start.Line, Character: link.Range.Start.Character},
 			)
 			if err != nil {
 				t.Fatalf("could not get offset of link in file: %s", err)
 			}
 			end, err := state.OffsetInFile(
-				fileText,
+				lines,
 				state.Position{Line: link.Range.End.Line, Character: link.Range.End.Character},
 			)
 			if err != nil {
diff --git a/fidl-lsp/langserver/testutils.go b/fidl-lsp/langserver/testutils.go
index fb0a618..43ce4c5 100644
--- a/fidl-lsp/langserver/testutils.go
+++ b/fidl-lsp/langserver/testutils.go
@@ -31,6 +31,7 @@
 		log.New(os.Stderr, "[LSP Test] ", log.Lshortfile),
 		analysis.NewAnalyzer(
 			analysis.Config{
+				BuildRootDir:   fuchsiaDir,
 				FidlcPath:      fidlcPath,
 				FidlLintPath:   fidlLintPath,
 				FidlFormatPath: fidlFormatPath,
diff --git a/fidl-lsp/main.go b/fidl-lsp/main.go
index aed5295..8a387bd 100644
--- a/fidl-lsp/main.go
+++ b/fidl-lsp/main.go
@@ -51,12 +51,13 @@
 
 	// fidl_project.json is generated by running
 	// `python3 scripts/gen_fidl_project.py path/to/fidl_project.json`
-	fidlProject, err := analysis.GetFidlProject(fidlProjectPath)
+	fidlProject, err := analysis.LoadFidlProject(fidlProjectPath)
 	if err != nil {
 		trace.Fatalf("Failed to parse fidl_project.json at `%s`: %s\n", fidlProjectPath, err)
 	}
 	analyzer := analysis.NewAnalyzer(
 		analysis.Config{
+			BuildRootDir:   fuchsiaDir,
 			FidlcPath:      fidlcPath,
 			FidlLintPath:   fidlLintPath,
 			FidlFormatPath: fidlFormatPath,
diff --git a/fidl-lsp/state/fs.go b/fidl-lsp/state/fs.go
index b018490..b6eb790 100644
--- a/fidl-lsp/state/fs.go
+++ b/fidl-lsp/state/fs.go
@@ -23,7 +23,12 @@
 // files on the client.
 type FileSystem struct {
 	// TODO: should this be a map[FileID][]byte instead?
-	files map[FileID]string
+
+	// `files` is stored as a map of FileIDs to files that are split by line
+	// (but retaining their '\n' characters).
+	// This speeds up the common operation of searching for a symbol at a given
+	// (line, column) in the file as the file is indexed by line.
+	files map[FileID][]string
 
 	// TODO: RWLock for safe concurrency
 }
@@ -42,6 +47,12 @@
 	Character int
 }
 
+// Location represents a range in a particular file.
+type Location struct {
+	FileID FileID
+	Range  Range
+}
+
 // Change represents a single diff in a source file.
 // Range is the range that is being changed. RangeLength is the length of the
 // replacement text (0 if the range is being deleted). Text is the text to
@@ -54,27 +65,27 @@
 // NewFileSystem returns an initialized empty FileSystem.
 func NewFileSystem() *FileSystem {
 	return &FileSystem{
-		files: make(map[FileID]string),
+		files: make(map[FileID][]string),
 	}
 }
 
 // File is a read-only accessor for the in-memory file with ID `path`.
 func (fs *FileSystem) File(path FileID) (string, error) {
-	if file, ok := fs.files[path]; ok {
-		return file, nil
+	if lines, ok := fs.files[path]; ok {
+		return strings.Join(lines, ""), nil
 	}
 	return "", fmt.Errorf("file `%s` not in memory", path)
 }
 
-// Files returns all of the in-memory files for traversal.
-func (fs *FileSystem) Files() map[FileID]string {
-	return fs.files
+// NFiles returns the number of in-memory files.
+func (fs *FileSystem) NFiles() int {
+	return len(fs.files)
 }
 
 // NewFile copies `text` into the FileSystem's in-memory file system ("opening"
 // the file), indexed by the FileID `path`.
 func (fs *FileSystem) NewFile(path FileID, text string) {
-	fs.files[path] = text
+	fs.files[path] = strings.SplitAfter(text, "\n")
 }
 
 // DeleteFile deletes the FileSystem's in-memory representation of the file
@@ -95,51 +106,47 @@
 			return err
 		}
 
+		file := strings.Join(fs.files[path], "")
+
 		// Copy file over to new buffer with inserted text change
 		var newFile strings.Builder
-		newFile.WriteString(fs.files[path][:start])
+		newFile.WriteString(file[:start])
 		newFile.WriteString(change.NewContent)
-		newFile.WriteString(fs.files[path][end:])
-		fs.files[path] = newFile.String()
+		newFile.WriteString(file[end:])
+		fs.files[path] = strings.SplitAfter(newFile.String(), "\n")
 	}
 	return nil
 }
 
 // OffsetInFile converts a Position (line, character) to a rune offset in
 // `contents`.
-func OffsetInFile(contents string, pos Position) (uint, error) {
-	var (
-		line, col int
-		offset    uint
-	)
-	for _, c := range contents {
-		if line == pos.Line && col == pos.Character {
-			return offset, nil
-		}
-		offset++
-		if c == '\n' {
-			line++
-			col = 0
-		} else {
-			col++
+func OffsetInFile(lines []string, pos Position) (uint, error) {
+	var offset uint
+	for line, l := range lines {
+		for col := range l {
+			if line == pos.Line && col == pos.Character {
+				return offset, nil
+			}
+			offset++
 		}
 	}
 	// Check if pos is pointing at the end of the file
-	if line == pos.Line && col == pos.Character {
+	lastLine := len(lines) - 1
+	if pos.Line == lastLine && pos.Character == len(lines[lastLine]) {
 		return offset, nil
 	}
 	return 0, fmt.Errorf(
 		"position %#v was out of bounds in file with %d lines, %d total chars",
 		pos,
-		line+1,
+		len(lines),
 		offset,
 	)
 }
 
 // FileIDToPath converts a FileID, which is in the form of a file schema URI
 // ("file:///"), to an absolute path.
-func FileIDToPath(editorFile FileID) (string, error) {
-	fileURI, err := url.Parse(string(editorFile))
+func FileIDToPath(fileID FileID) (string, error) {
+	fileURI, err := url.Parse(string(fileID))
 	if err != nil {
 		return "", fmt.Errorf("could not parse FileID: %s", err)
 	}
diff --git a/fidl-lsp/state/parse.go b/fidl-lsp/state/parse.go
index 083f822..f91f9b3 100644
--- a/fidl-lsp/state/parse.go
+++ b/fidl-lsp/state/parse.go
@@ -103,3 +103,67 @@
 		},
 	}
 }
+
+// https://fuchsia.dev/fuchsia-src/development/languages/fidl/reference/language.md#identifiers
+//
+// This also includes a '.' as long as it is not initial or trailing, so we can
+// capture fully-qualified names and library names.
+var identRegexp = regexp.MustCompile(`\b[a-zA-Z](?:[a-zA-Z0-9_.]*[a-zA-Z0-9])?\b`)
+
+// Symbol represents a FIDL identifier.
+// It could be a fully-qualified name, a local declaration name, a library name,
+// etc.
+type Symbol struct {
+	Name     string
+	Location Location
+}
+
+// SymbolAtPos returns the FIDL identifier that is at `pos` in `file`.
+func (fs *FileSystem) SymbolAtPos(path FileID, pos Position) (Symbol, error) {
+	lines, ok := fs.files[path]
+	if !ok {
+		return Symbol{}, fmt.Errorf("could not find file `%s`", path)
+	}
+
+	if pos.Line >= len(lines) {
+		return Symbol{}, fmt.Errorf("position %#v in file `%s` is out of bounds", pos, path)
+	}
+	line := lines[pos.Line]
+	tokens := identRegexp.FindAllStringIndex(line, -1)
+	if len(tokens) == 0 {
+		return Symbol{}, fmt.Errorf("could not find any symbols in line %d in file `%s`", pos.Line, path)
+	}
+
+	var name string
+	r := Range{
+		Start: Position{Line: pos.Line},
+		End:   Position{Line: pos.Line},
+	}
+	for _, token := range tokens {
+		if pos.Character >= token[0] &&
+			pos.Character <= token[1] {
+			name = line[token[0]:token[1]]
+			r.Start.Character = token[0]
+			r.End.Character = token[1]
+			break
+		}
+	}
+	if name == "" {
+		return Symbol{}, fmt.Errorf("could not find symbol at pos %#v in file `%s`", pos, path)
+	}
+
+	// TODO: could exclude all FIDL keywords, to save work
+
+	// TODO: check that the symbol is not part of a comment: if the line
+	// contains "//" and the symbol is after the "//", then don't return the
+	// symbol. "//" could also be part of a string const, but that also wouldn't
+	// be an identifer.
+
+	return Symbol{
+		Name: name,
+		Location: Location{
+			FileID: path,
+			Range:  r,
+		},
+	}, nil
+}
diff --git a/fidl-lsp/state/parse_test.go b/fidl-lsp/state/parse_test.go
index cb050ee..6e32a0d 100644
--- a/fidl-lsp/state/parse_test.go
+++ b/fidl-lsp/state/parse_test.go
@@ -130,3 +130,158 @@
 		}
 	}
 }
+
+func TestSymbolAtPos(t *testing.T) {
+	fs := state.NewFileSystem()
+	fs.NewFile("test.fidl", `
+library example;
+using test.import;
+
+struct Foo {
+    uint8 bar_baz;
+};
+`)
+
+	cases := []struct {
+		pos state.Position
+		sym state.Symbol
+	}{
+		{
+			pos: state.Position{Line: 1, Character: 0},
+			sym: state.Symbol{
+				Name: "library",
+				Location: state.Location{
+					FileID: state.FileID("test.fidl"),
+					Range: state.Range{
+						Start: state.Position{Line: 1, Character: 0},
+						End:   state.Position{Line: 1, Character: 7},
+					},
+				},
+			},
+		},
+		{
+			pos: state.Position{Line: 1, Character: 15},
+			sym: state.Symbol{
+				Name: "example",
+				Location: state.Location{
+					FileID: state.FileID("test.fidl"),
+					Range: state.Range{
+						Start: state.Position{Line: 1, Character: 8},
+						End:   state.Position{Line: 1, Character: 15},
+					},
+				},
+			},
+		},
+		{
+			pos: state.Position{Line: 2, Character: 11},
+			sym: state.Symbol{
+				Name: "test.import",
+				Location: state.Location{
+					FileID: state.FileID("test.fidl"),
+					Range: state.Range{
+						Start: state.Position{Line: 2, Character: 6},
+						End:   state.Position{Line: 2, Character: 17},
+					},
+				},
+			},
+		},
+		{
+			pos: state.Position{Line: 4, Character: 10},
+			sym: state.Symbol{
+				Name: "Foo",
+				Location: state.Location{
+					FileID: state.FileID("test.fidl"),
+					Range: state.Range{
+						Start: state.Position{Line: 4, Character: 7},
+						End:   state.Position{Line: 4, Character: 10},
+					},
+				},
+			},
+		},
+		{
+			pos: state.Position{Line: 5, Character: 13},
+			sym: state.Symbol{
+				Name: "bar_baz",
+				Location: state.Location{
+					FileID: state.FileID("test.fidl"),
+					Range: state.Range{
+						Start: state.Position{Line: 5, Character: 10},
+						End:   state.Position{Line: 5, Character: 17},
+					},
+				},
+			},
+		},
+	}
+
+	for _, ex := range cases {
+		sym, err := fs.SymbolAtPos(
+			state.FileID("test.fidl"),
+			ex.pos,
+		)
+		if err != nil {
+			t.Fatalf("error finding symbol: %s", err)
+		}
+		if sym.Name != ex.sym.Name {
+			t.Fatalf("did not find correct symbol; expected %s, found %s", ex.sym.Name, sym.Name)
+		}
+		if sym.Location.FileID != ex.sym.Location.FileID {
+			t.Fatalf(
+				"incorrect file; expected %s, found %s",
+				ex.sym.Location.FileID,
+				sym.Location.FileID,
+			)
+		}
+		if sym.Location.Range != ex.sym.Location.Range {
+			t.Fatalf(
+				"incorrect range; expected %#v, found %#v",
+				ex.sym.Location.Range,
+				sym.Location.Range,
+			)
+		}
+	}
+}
+
+func TestNoSymbolAtPos(t *testing.T) {
+	fs := state.NewFileSystem()
+	fs.NewFile("test.fidl", `
+//							 ^ should not be a symbol
+library example;
+//			   ^ should not be a symbol
+
+struct Foo {
+//		   ^ should not be a symbol
+    uint8 foo;
+//^ should not be a symbol
+};
+
+.prepended.dot
+^ should not be a symbol ('prepended.dot' is a valid symbol)
+trailing.dot.
+			^ should not be a symbol  ('trailing.dot' is a valid symbol)
+0starts_with_a_number
+	^ should not be a symbol
+ends_with_a_semicolon_
+	^ should not be a symbol
+`)
+
+	cases := []state.Position{
+		{Line: 0, Character: 0},
+		{Line: 2, Character: 16},
+		{Line: 5, Character: 11},
+		{Line: 7, Character: 2},
+		{Line: 11, Character: 0},
+		{Line: 13, Character: 13},
+		{Line: 15, Character: 3},
+		{Line: 17, Character: 3},
+	}
+
+	for _, ex := range cases {
+		sym, err := fs.SymbolAtPos(
+			state.FileID("test.fidl"),
+			ex,
+		)
+		if err == nil {
+			t.Errorf("expected not to find symbol at position %v, but found %s", ex, sym.Name)
+		}
+	}
+}